1# Copyright 2019 The ANGLE Project Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4"""Top-level presubmit script for code generation. 5 6See http://dev.chromium.org/developers/how-tos/depottools/presubmit-scripts 7for more details on the presubmit API built into depot_tools. 8""" 9 10import itertools 11import os 12import re 13import shutil 14import subprocess 15import sys 16import tempfile 17import textwrap 18import pathlib 19 20# This line is 'magic' in that git-cl looks for it to decide whether to 21# use Python3 instead of Python2 when running the code in this file. 22USE_PYTHON3 = True 23 24# Fragment of a regular expression that matches C/C++ and Objective-C++ implementation files and headers. 25_IMPLEMENTATION_AND_HEADER_EXTENSIONS = r'\.(c|cc|cpp|cxx|mm|h|hpp|hxx)$' 26 27# Fragment of a regular expression that matches C++ and Objective-C++ header files. 28_HEADER_EXTENSIONS = r'\.(h|hpp|hxx)$' 29 30_PRIMARY_EXPORT_TARGETS = [ 31 '//:libEGL', 32 '//:libGLESv1_CM', 33 '//:libGLESv2', 34 '//:translator', 35] 36 37 38def _SplitIntoMultipleCommits(description_text): 39 paragraph_split_pattern = r"(?m)(^\s*$\n)" 40 multiple_paragraphs = re.split(paragraph_split_pattern, description_text) 41 multiple_commits = [""] 42 change_id_pattern = re.compile(r"(?m)^Change-Id: [a-zA-Z0-9]*$") 43 for paragraph in multiple_paragraphs: 44 multiple_commits[-1] += paragraph 45 if change_id_pattern.search(paragraph): 46 multiple_commits.append("") 47 if multiple_commits[-1] == "": 48 multiple_commits.pop() 49 return multiple_commits 50 51 52def _CheckCommitMessageFormatting(input_api, output_api): 53 54 def _IsLineBlank(line): 55 return line.isspace() or line == "" 56 57 def _PopBlankLines(lines, reverse=False): 58 if reverse: 59 while len(lines) > 0 and _IsLineBlank(lines[-1]): 60 lines.pop() 61 else: 62 while len(lines) > 0 and _IsLineBlank(lines[0]): 63 lines.pop(0) 64 65 def _IsTagLine(line): 66 return ":" in line 67 68 def _CheckTabInCommit(lines): 69 return all([line.find("\t") == -1 for line in lines]) 70 71 allowlist_strings = ['Revert', 'Roll', 'Manual roll', 'Reland', 'Re-land'] 72 summary_linelength_warning_lower_limit = 65 73 summary_linelength_warning_upper_limit = 70 74 description_linelength_limit = 72 75 76 git_output = input_api.change.DescriptionText() 77 78 multiple_commits = _SplitIntoMultipleCommits(git_output) 79 errors = [] 80 81 for k in range(len(multiple_commits)): 82 commit_msg_lines = multiple_commits[k].splitlines() 83 commit_number = len(multiple_commits) - k 84 commit_tag = "Commit " + str(commit_number) + ":" 85 commit_msg_line_numbers = {} 86 for i in range(len(commit_msg_lines)): 87 commit_msg_line_numbers[commit_msg_lines[i]] = i + 1 88 _PopBlankLines(commit_msg_lines, True) 89 _PopBlankLines(commit_msg_lines, False) 90 allowlisted = False 91 if len(commit_msg_lines) > 0: 92 for allowlist_string in allowlist_strings: 93 if commit_msg_lines[0].startswith(allowlist_string): 94 allowlisted = True 95 break 96 if allowlisted: 97 continue 98 99 if not _CheckTabInCommit(commit_msg_lines): 100 errors.append( 101 output_api.PresubmitError(commit_tag + "Tabs are not allowed in commit message.")) 102 103 # the tags paragraph is at the end of the message 104 # the break between the tags paragraph is the first line without ":" 105 # this is sufficient because if a line is blank, it will not have ":" 106 last_paragraph_line_count = 0 107 while len(commit_msg_lines) > 0 and _IsTagLine(commit_msg_lines[-1]): 108 last_paragraph_line_count += 1 109 commit_msg_lines.pop() 110 if last_paragraph_line_count == 0: 111 errors.append( 112 output_api.PresubmitError( 113 commit_tag + 114 "Please ensure that there are tags (e.g., Bug:, Test:) in your description.")) 115 if len(commit_msg_lines) > 0: 116 if not _IsLineBlank(commit_msg_lines[-1]): 117 output_api.PresubmitError(commit_tag + 118 "Please ensure that there exists 1 blank line " + 119 "between tags and description body.") 120 else: 121 # pop the blank line between tag paragraph and description body 122 commit_msg_lines.pop() 123 if len(commit_msg_lines) > 0 and _IsLineBlank(commit_msg_lines[-1]): 124 errors.append( 125 output_api.PresubmitError( 126 commit_tag + 'Please ensure that there exists only 1 blank line ' 127 'between tags and description body.')) 128 # pop all the remaining blank lines between tag and description body 129 _PopBlankLines(commit_msg_lines, True) 130 if len(commit_msg_lines) == 0: 131 errors.append( 132 output_api.PresubmitError(commit_tag + 133 'Please ensure that your description summary' 134 ' and description body are not blank.')) 135 continue 136 137 if summary_linelength_warning_lower_limit <= len(commit_msg_lines[0]) \ 138 <= summary_linelength_warning_upper_limit: 139 errors.append( 140 output_api.PresubmitPromptWarning( 141 commit_tag + "Your description summary should be on one line of " + 142 str(summary_linelength_warning_lower_limit - 1) + " or less characters.")) 143 elif len(commit_msg_lines[0]) > summary_linelength_warning_upper_limit: 144 errors.append( 145 output_api.PresubmitError( 146 commit_tag + "Please ensure that your description summary is on one line of " + 147 str(summary_linelength_warning_lower_limit - 1) + " or less characters.")) 148 commit_msg_lines.pop(0) # get rid of description summary 149 if len(commit_msg_lines) == 0: 150 continue 151 if not _IsLineBlank(commit_msg_lines[0]): 152 errors.append( 153 output_api.PresubmitError(commit_tag + 154 'Please ensure the summary is only 1 line and ' 155 'there is 1 blank line between the summary ' 156 'and description body.')) 157 else: 158 commit_msg_lines.pop(0) # pop first blank line 159 if len(commit_msg_lines) == 0: 160 continue 161 if _IsLineBlank(commit_msg_lines[0]): 162 errors.append( 163 output_api.PresubmitError(commit_tag + 164 'Please ensure that there exists only 1 blank line ' 165 'between description summary and description body.')) 166 # pop all the remaining blank lines between 167 # description summary and description body 168 _PopBlankLines(commit_msg_lines) 169 170 # loop through description body 171 while len(commit_msg_lines) > 0: 172 line = commit_msg_lines.pop(0) 173 # lines starting with 4 spaces, quotes or lines without space(urls) 174 # are exempt from length check 175 if line.startswith(" ") or line.startswith("> ") or " " not in line: 176 continue 177 if len(line) > description_linelength_limit: 178 errors.append( 179 output_api.PresubmitError( 180 commit_tag + 'Line ' + str(commit_msg_line_numbers[line]) + 181 ' is too long.\n' + '"' + line + '"\n' + 'Please wrap it to ' + 182 str(description_linelength_limit) + ' characters. ' + 183 "Lines without spaces or lines starting with 4 spaces are exempt.")) 184 break 185 return errors 186 187 188def _CheckChangeHasBugField(input_api, output_api): 189 """Requires that the changelist have a Bug: field from a known project.""" 190 bugs = input_api.change.BugsFromDescription() 191 192 # The bug must be in the form of "project:number". None is also accepted, which is used by 193 # rollers as well as in very minor changes. 194 if len(bugs) == 1 and bugs[0] == 'None': 195 return [] 196 197 projects = [ 198 'angleproject:', 'chromium:', 'dawn:', 'fuchsia:', 'skia:', 'swiftshader:', 'tint:', 'b/' 199 ] 200 bug_regex = re.compile(r"([a-z]+[:/])(\d+)") 201 errors = [] 202 extra_help = False 203 204 if not bugs: 205 errors.append('Please ensure that your description contains\n' 206 'Bug: bugtag\n' 207 'directly above the Change-Id tag (no empty line in-between)') 208 extra_help = True 209 210 for bug in bugs: 211 if bug == 'None': 212 errors.append('Invalid bug tag "None" in presence of other bug tags.') 213 continue 214 215 match = re.match(bug_regex, bug) 216 if match == None or bug != match.group(0) or match.group(1) not in projects: 217 errors.append('Incorrect bug tag "' + bug + '".') 218 extra_help = True 219 220 if extra_help: 221 change_ids = re.findall('^Change-Id:', input_api.change.FullDescriptionText(), re.M) 222 if len(change_ids) > 1: 223 errors.append('Note: multiple Change-Id tags found in description') 224 225 errors.append('''Acceptable bugtags: 226 project:bugnumber - where project is one of ({projects}) 227 b/bugnumber - for Buganizer/IssueTracker bugs 228'''.format(projects=', '.join(p[:-1] for p in projects if p != 'b/'))) 229 230 return [output_api.PresubmitError('\n\n'.join(errors))] if errors else [] 231 232 233def _CheckCodeGeneration(input_api, output_api): 234 235 class Msg(output_api.PresubmitError): 236 """Specialized error message""" 237 238 def __init__(self, message, **kwargs): 239 super(output_api.PresubmitError, self).__init__( 240 message, 241 long_text='Please ensure your ANGLE repositiory is synced to tip-of-tree\n' 242 'and all ANGLE DEPS are fully up-to-date by running gclient sync.\n' 243 '\n' 244 'If that fails, run scripts/run_code_generation.py to refresh generated hashes.\n' 245 '\n' 246 'If you are building ANGLE inside Chromium you must bootstrap ANGLE\n' 247 'before gclient sync. See the DevSetup documentation for more details.\n', 248 **kwargs) 249 250 code_gen_path = input_api.os_path.join(input_api.PresubmitLocalPath(), 251 'scripts/run_code_generation.py') 252 cmd_name = 'run_code_generation' 253 cmd = [input_api.python3_executable, code_gen_path, '--verify-no-dirty'] 254 test_cmd = input_api.Command(name=cmd_name, cmd=cmd, kwargs={}, message=Msg) 255 if input_api.verbose: 256 print('Running ' + cmd_name) 257 return input_api.RunTests([test_cmd]) 258 259 260# Taken directly from Chromium's PRESUBMIT.py 261def _CheckNewHeaderWithoutGnChange(input_api, output_api): 262 """Checks that newly added header files have corresponding GN changes. 263 Note that this is only a heuristic. To be precise, run script: 264 build/check_gn_headers.py. 265 """ 266 267 def headers(f): 268 return input_api.FilterSourceFile(f, files_to_check=(r'.+%s' % _HEADER_EXTENSIONS,)) 269 270 new_headers = [] 271 for f in input_api.AffectedSourceFiles(headers): 272 if f.Action() != 'A': 273 continue 274 new_headers.append(f.LocalPath()) 275 276 def gn_files(f): 277 return input_api.FilterSourceFile(f, files_to_check=(r'.+\.gn',)) 278 279 all_gn_changed_contents = '' 280 for f in input_api.AffectedSourceFiles(gn_files): 281 for _, line in f.ChangedContents(): 282 all_gn_changed_contents += line 283 284 problems = [] 285 for header in new_headers: 286 basename = input_api.os_path.basename(header) 287 if basename not in all_gn_changed_contents: 288 problems.append(header) 289 290 if problems: 291 return [ 292 output_api.PresubmitPromptWarning( 293 'Missing GN changes for new header files', 294 items=sorted(problems), 295 long_text='Please double check whether newly added header files need ' 296 'corresponding changes in gn or gni files.\nThis checking is only a ' 297 'heuristic. Run build/check_gn_headers.py to be precise.\n' 298 'Read https://crbug.com/661774 for more info.') 299 ] 300 return [] 301 302 303def _CheckExportValidity(input_api, output_api): 304 outdir = tempfile.mkdtemp() 305 # shell=True is necessary on Windows, as otherwise subprocess fails to find 306 # either 'gn' or 'vpython3' even if they are findable via PATH. 307 use_shell = input_api.is_windows 308 try: 309 try: 310 subprocess.check_output(['gn', 'gen', outdir], shell=use_shell) 311 except subprocess.CalledProcessError as e: 312 return [ 313 output_api.PresubmitError('Unable to run gn gen for export_targets.py: %s' % 314 e.output.decode()) 315 ] 316 export_target_script = os.path.join(input_api.PresubmitLocalPath(), 'scripts', 317 'export_targets.py') 318 try: 319 subprocess.check_output( 320 ['vpython3', export_target_script, outdir] + _PRIMARY_EXPORT_TARGETS, 321 stderr=subprocess.STDOUT, 322 shell=use_shell) 323 except subprocess.CalledProcessError as e: 324 if input_api.is_committing: 325 return [ 326 output_api.PresubmitError('export_targets.py failed: %s' % e.output.decode()) 327 ] 328 return [ 329 output_api.PresubmitPromptWarning( 330 'export_targets.py failed, this may just be due to your local checkout: %s' % 331 e.output.decode()) 332 ] 333 return [] 334 finally: 335 shutil.rmtree(outdir) 336 337 338def _CheckTabsInSourceFiles(input_api, output_api): 339 """Forbids tab characters in source files due to a WebKit repo requirement.""" 340 341 def implementation_and_headers_including_third_party(f): 342 # Check third_party files too, because WebKit's checks don't make exceptions. 343 return input_api.FilterSourceFile( 344 f, 345 files_to_check=(r'.+%s' % _IMPLEMENTATION_AND_HEADER_EXTENSIONS,), 346 files_to_skip=[f for f in input_api.DEFAULT_FILES_TO_SKIP if not "third_party" in f]) 347 348 files_with_tabs = [] 349 for f in input_api.AffectedSourceFiles(implementation_and_headers_including_third_party): 350 for (num, line) in f.ChangedContents(): 351 if '\t' in line: 352 files_with_tabs.append(f) 353 break 354 355 if files_with_tabs: 356 return [ 357 output_api.PresubmitError( 358 'Tab characters in source files.', 359 items=sorted(files_with_tabs), 360 long_text= 361 'Tab characters are forbidden in ANGLE source files because WebKit\'s Subversion\n' 362 'repository does not allow tab characters in source files.\n' 363 'Please remove tab characters from these files.') 364 ] 365 return [] 366 367 368# https://stackoverflow.com/a/196392 369def is_ascii(s): 370 return all(ord(c) < 128 for c in s) 371 372 373def _CheckNonAsciiInSourceFiles(input_api, output_api): 374 """Forbids non-ascii characters in source files.""" 375 376 def implementation_and_headers(f): 377 return input_api.FilterSourceFile( 378 f, files_to_check=(r'.+%s' % _IMPLEMENTATION_AND_HEADER_EXTENSIONS,)) 379 380 files_with_non_ascii = [] 381 for f in input_api.AffectedSourceFiles(implementation_and_headers): 382 for (num, line) in f.ChangedContents(): 383 if not is_ascii(line): 384 files_with_non_ascii.append("%s: %s" % (f, line)) 385 break 386 387 if files_with_non_ascii: 388 return [ 389 output_api.PresubmitError( 390 'Non-ASCII characters in source files.', 391 items=sorted(files_with_non_ascii), 392 long_text='Non-ASCII characters are forbidden in ANGLE source files.\n' 393 'Please remove non-ASCII characters from these files.') 394 ] 395 return [] 396 397 398def _CheckCommentBeforeTestInTestFiles(input_api, output_api): 399 """Require a comment before TEST_P() and other tests.""" 400 401 def test_files(f): 402 return input_api.FilterSourceFile( 403 f, files_to_check=(r'^src/tests/.+\.cpp$', r'^src/.+_unittest\.cpp$')) 404 405 tests_with_no_comment = [] 406 for f in input_api.AffectedSourceFiles(test_files): 407 diff = f.GenerateScmDiff() 408 last_line_was_comment = False 409 for line in diff.splitlines(): 410 # Skip removed lines 411 if line.startswith('-'): 412 continue 413 414 new_line_is_comment = line.startswith(' //') or line.startswith('+//') 415 new_line_is_test_declaration = ( 416 line.startswith('+TEST_P(') or line.startswith('+TEST(') or 417 line.startswith('+TYPED_TEST(')) 418 419 if new_line_is_test_declaration and not last_line_was_comment: 420 tests_with_no_comment.append(line[1:]) 421 422 last_line_was_comment = new_line_is_comment 423 424 if tests_with_no_comment: 425 return [ 426 output_api.PresubmitError( 427 'Tests without comment.', 428 items=sorted(tests_with_no_comment), 429 long_text='ANGLE requires a comment describing what a test does.') 430 ] 431 return [] 432 433 434def _CheckWildcardInTestExpectationFiles(input_api, output_api): 435 """Require wildcard as API tag (i.e. in foo.bar/*) in expectations when no additional feature is 436 enabled.""" 437 438 def expectation_files(f): 439 return input_api.FilterSourceFile( 440 f, files_to_check=[r'^src/tests/angle_end2end_tests_expectations.txt$']) 441 442 expectation_pattern = re.compile(r'^.*:\s*[a-zA-Z0-9._*]+\/([^ ]*)\s*=.*$') 443 444 expectations_without_wildcard = [] 445 for f in input_api.AffectedSourceFiles(expectation_files): 446 diff = f.GenerateScmDiff() 447 for line in diff.splitlines(): 448 # Only look at new lines 449 if not line.startswith('+'): 450 continue 451 452 match = re.match(expectation_pattern, line[1:].strip()) 453 if match is None: 454 continue 455 456 tag = match.group(1) 457 458 # The tag is in the following general form: 459 # 460 # FRONTENDAPI_BACKENDAPI[_FEATURE]* 461 # 462 # Any part of the above may be a wildcard. Warn about usage of FRONTEND_BACKENDAPI as 463 # the tag. Instead, the backend should be specified before the : and `*` used as the 464 # tag. If any additional tags are present, it's a specific expectation that should 465 # remain specific (and not wildcarded). NoFixture is an exception as X_Y_NoFixture is 466 # the generic form of the tags of tests that don't use the fixture. 467 468 sections = [section for section in tag.split('_') if section != 'NoFixture'] 469 470 # Allow '*_...', or 'FRONTENDAPI_*_...'. 471 if '*' in sections[0] or (len(sections) > 1 and '*' in sections[1]): 472 continue 473 474 # Warn if no additional tags are present 475 if len(sections) == 2: 476 expectations_without_wildcard.append(line[1:]) 477 478 if expectations_without_wildcard: 479 return [ 480 output_api.PresubmitError( 481 'Use wildcard in API tags (after /) in angle_end2end_tests_expectations.txt.', 482 items=expectations_without_wildcard, 483 long_text="""ANGLE prefers end2end expections to use the following form: 484 4851234 MAC OPENGL : Foo.Bar/* = SKIP 486 487instead of: 488 4891234 MAC OPENGL : Foo.Bar/ES2_OpenGL = SKIP 4901234 MAC OPENGL : Foo.Bar/ES3_OpenGL = SKIP 491 492Expectatations that are specific (such as Foo.Bar/ES2_OpenGL_SomeFeature) are allowed.""") 493 ] 494 return [] 495 496 497def _CheckShaderVersionInShaderLangHeader(input_api, output_api): 498 """Requires an update to ANGLE_SH_VERSION when ShaderLang.h or ShaderVars.h change.""" 499 500 def headers(f): 501 return input_api.FilterSourceFile( 502 f, 503 files_to_check=(r'^include/GLSLANG/ShaderLang.h$', r'^include/GLSLANG/ShaderVars.h$')) 504 505 headers_changed = input_api.AffectedSourceFiles(headers) 506 if len(headers_changed) == 0: 507 return [] 508 509 # Skip this check for reverts and rolls. Unlike 510 # _CheckCommitMessageFormatting, relands are still checked because the 511 # original change might have incremented the version correctly, but the 512 # rebase over a new version could accidentally remove that (because another 513 # change in the meantime identically incremented it). 514 git_output = input_api.change.DescriptionText() 515 multiple_commits = _SplitIntoMultipleCommits(git_output) 516 for commit in multiple_commits: 517 if commit.startswith('Revert') or commit.startswith('Roll'): 518 return [] 519 520 diffs = '\n'.join(f.GenerateScmDiff() for f in headers_changed) 521 versions = dict(re.findall(r'^([-+])#define ANGLE_SH_VERSION\s+(\d+)', diffs, re.M)) 522 523 if len(versions) != 2 or int(versions['+']) <= int(versions['-']): 524 return [ 525 output_api.PresubmitError( 526 'ANGLE_SH_VERSION should be incremented when ShaderLang.h or ShaderVars.h change.', 527 ) 528 ] 529 return [] 530 531 532def _CheckGClientExists(input_api, output_api, search_limit=None): 533 presubmit_path = pathlib.Path(input_api.PresubmitLocalPath()) 534 535 for current_path in itertools.chain([presubmit_path], presubmit_path.parents): 536 gclient_path = current_path.joinpath('.gclient') 537 if gclient_path.exists() and gclient_path.is_file(): 538 return [] 539 # search_limit parameter is used in unit tests to prevent searching all the way to root 540 # directory for reproducibility. 541 elif search_limit != None and current_path == search_limit: 542 break 543 544 return [ 545 output_api.PresubmitError( 546 'Missing .gclient file.', 547 long_text=textwrap.fill( 548 width=100, 549 text='The top level directory of the repository must contain a .gclient file.' 550 ' You can follow the steps outlined in the link below to get set up for ANGLE' 551 ' development:') + 552 '\n\nhttps://chromium.googlesource.com/angle/angle/+/refs/heads/main/doc/DevSetup.md') 553 ] 554 555def CheckChangeOnUpload(input_api, output_api): 556 results = [] 557 results.extend(input_api.canned_checks.CheckForCommitObjects(input_api, output_api)) 558 results.extend(_CheckTabsInSourceFiles(input_api, output_api)) 559 results.extend(_CheckNonAsciiInSourceFiles(input_api, output_api)) 560 results.extend(_CheckCommentBeforeTestInTestFiles(input_api, output_api)) 561 results.extend(_CheckWildcardInTestExpectationFiles(input_api, output_api)) 562 results.extend(_CheckShaderVersionInShaderLangHeader(input_api, output_api)) 563 results.extend(_CheckCodeGeneration(input_api, output_api)) 564 results.extend(_CheckChangeHasBugField(input_api, output_api)) 565 results.extend(input_api.canned_checks.CheckChangeHasDescription(input_api, output_api)) 566 results.extend(_CheckNewHeaderWithoutGnChange(input_api, output_api)) 567 results.extend(_CheckExportValidity(input_api, output_api)) 568 results.extend( 569 input_api.canned_checks.CheckPatchFormatted( 570 input_api, output_api, result_factory=output_api.PresubmitError)) 571 results.extend(_CheckCommitMessageFormatting(input_api, output_api)) 572 results.extend(_CheckGClientExists(input_api, output_api)) 573 574 return results 575 576 577def CheckChangeOnCommit(input_api, output_api): 578 return CheckChangeOnUpload(input_api, output_api) 579