xref: /aosp_15_r20/external/angle/PRESUBMIT.py (revision 8975f5c5ed3d1c378011245431ada316dfb6f244)
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