• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1#!/usr/bin/env python3
2
3# Copyright 2020 The Pigweed Authors
4#
5# Licensed under the Apache License, Version 2.0 (the "License"); you may not
6# use this file except in compliance with the License. You may obtain a copy of
7# the License at
8#
9#     https://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14# License for the specific language governing permissions and limitations under
15# the License.
16"""Runs the local presubmit checks for the Pigweed repository."""
17
18import argparse
19import json
20import logging
21import os
22from pathlib import Path
23import re
24import shutil
25import subprocess
26import sys
27from typing import Callable, Iterable, List, Sequence, TextIO
28
29try:
30    import pw_presubmit
31except ImportError:
32    # Append the pw_presubmit package path to the module search path to allow
33    # running this module without installing the pw_presubmit package.
34    sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
35    import pw_presubmit
36
37import pw_package.pigweed_packages
38
39from pw_presubmit import (
40    build,
41    cli,
42    cpp_checks,
43    format_code,
44    git_repo,
45    gitmodules,
46    call,
47    filter_paths,
48    inclusive_language,
49    keep_sorted,
50    module_owners,
51    npm_presubmit,
52    owners_checks,
53    plural,
54    presubmit,
55    PresubmitContext,
56    PresubmitFailure,
57    Programs,
58    python_checks,
59    shell_checks,
60    source_in_build,
61    todo_check,
62)
63from pw_presubmit.install_hook import install_git_hook
64
65_LOG = logging.getLogger(__name__)
66
67pw_package.pigweed_packages.initialize()
68
69# Trigger builds if files with these extensions change.
70_BUILD_FILE_FILTER = presubmit.FileFilter(
71    suffix=(
72        *format_code.C_FORMAT.extensions,
73        '.cfg',
74        '.py',
75        '.rst',
76        '.gn',
77        '.gni',
78        '.emb',
79    )
80)
81
82_OPTIMIZATION_LEVELS = 'debug', 'size_optimized', 'speed_optimized'
83
84
85def _at_all_optimization_levels(target):
86    for level in _OPTIMIZATION_LEVELS:
87        yield f'{target}_{level}'
88
89
90#
91# Build presubmit checks
92#
93def gn_clang_build(ctx: PresubmitContext):
94    """Checks all compile targets that rely on LLVM tooling."""
95    build_targets = [
96        *_at_all_optimization_levels('host_clang'),
97        'cpp14_compatibility',
98        'cpp20_compatibility',
99        'asan',
100        'tsan',
101        'ubsan',
102        'runtime_sanitizers',
103        # TODO(b/234876100): msan will not work until the C++ standard library
104        # included in the sysroot has a variant built with msan.
105    ]
106
107    # clang-tidy doesn't run on Windows.
108    if sys.platform != 'win32':
109        build_targets.append('static_analysis')
110
111    # QEMU doesn't run on Windows.
112    if sys.platform != 'win32':
113        # TODO(b/244604080): For the pw::InlineString tests, qemu_clang_debug
114        #     and qemu_clang_speed_optimized produce a binary too large for the
115        #     QEMU target's 256KB flash. Restore debug and speed optimized
116        #     builds when this is fixed.
117        build_targets.append('qemu_clang_size_optimized')
118
119    # TODO(b/240982565): SocketStream currently requires Linux.
120    if sys.platform.startswith('linux'):
121        build_targets.append('integration_tests')
122
123    build.gn_gen(ctx, pw_C_OPTIMIZATION_LEVELS=_OPTIMIZATION_LEVELS)
124    build.ninja(ctx, *build_targets)
125
126
127_HOST_COMPILER = 'gcc' if sys.platform == 'win32' else 'clang'
128
129
130@_BUILD_FILE_FILTER.apply_to_check()
131def gn_quick_build_check(ctx: PresubmitContext):
132    """Checks the state of the GN build by running gn gen and gn check."""
133    build.gn_gen(ctx)
134
135
136@_BUILD_FILE_FILTER.apply_to_check()
137def gn_full_qemu_check(ctx: PresubmitContext):
138    build.gn_gen(ctx, pw_C_OPTIMIZATION_LEVELS=_OPTIMIZATION_LEVELS)
139    build.ninja(
140        ctx,
141        *_at_all_optimization_levels('qemu_gcc'),
142        *_at_all_optimization_levels('qemu_clang'),
143    )
144
145
146def _gn_combined_build_check_targets() -> Sequence[str]:
147    build_targets = [
148        'check_modules',
149        *_at_all_optimization_levels('stm32f429i'),
150        *_at_all_optimization_levels(f'host_{_HOST_COMPILER}'),
151        'python.tests',
152        'python.lint',
153        'docs',
154        'fuzzers',
155        'pigweed_pypi_distribution',
156    ]
157
158    # TODO(b/234645359): Re-enable on Windows when compatibility tests build.
159    if sys.platform != 'win32':
160        build_targets.append('cpp14_compatibility')
161        build_targets.append('cpp20_compatibility')
162
163    # clang-tidy doesn't run on Windows.
164    if sys.platform != 'win32':
165        build_targets.append('static_analysis')
166
167    # QEMU doesn't run on Windows.
168    if sys.platform != 'win32':
169        build_targets.extend(_at_all_optimization_levels('qemu_gcc'))
170
171        # TODO(b/244604080): For the pw::InlineString tests, qemu_clang_debug
172        #     and qemu_clang_speed_optimized produce a binary too large for the
173        #     QEMU target's 256KB flash. Restore debug and speed optimized
174        #     builds when this is fixed.
175        build_targets.append('qemu_clang_size_optimized')
176
177    # TODO(b/240982565): SocketStream currently requires Linux.
178    if sys.platform.startswith('linux'):
179        build_targets.append('integration_tests')
180
181    return build_targets
182
183
184gn_combined_build_check = build.GnGenNinja(
185    name='gn_combined_build_check',
186    doc='Run most host and device (QEMU) tests.',
187    path_filter=_BUILD_FILE_FILTER,
188    gn_args=dict(pw_C_OPTIMIZATION_LEVELS=_OPTIMIZATION_LEVELS),
189    ninja_targets=_gn_combined_build_check_targets(),
190)
191
192
193@_BUILD_FILE_FILTER.apply_to_check()
194def gn_arm_build(ctx: PresubmitContext):
195    build.gn_gen(ctx, pw_C_OPTIMIZATION_LEVELS=_OPTIMIZATION_LEVELS)
196    build.ninja(ctx, *_at_all_optimization_levels('stm32f429i'))
197
198
199stm32f429i = build.GnGenNinja(
200    name='stm32f429i',
201    path_filter=_BUILD_FILE_FILTER,
202    gn_args={
203        'pw_use_test_server': True,
204        'pw_C_OPTIMIZATION_LEVELS': _OPTIMIZATION_LEVELS,
205    },
206    ninja_contexts=(
207        lambda ctx: build.test_server(
208            'stm32f429i_disc1_test_server',
209            ctx.output_dir,
210        ),
211    ),
212    ninja_targets=_at_all_optimization_levels('stm32f429i'),
213)
214
215
216gn_emboss_build = build.GnGenNinja(
217    name='gn_emboss_build',
218    packages=('emboss',),
219    gn_args=dict(
220        dir_pw_third_party_emboss=lambda ctx: '"{}"'.format(
221            ctx.package_root / 'emboss'
222        ),
223        pw_C_OPTIMIZATION_LEVELS=_OPTIMIZATION_LEVELS,
224    ),
225    ninja_targets=(*_at_all_optimization_levels(f'host_{_HOST_COMPILER}'),),
226)
227
228gn_nanopb_build = build.GnGenNinja(
229    name='gn_nanopb_build',
230    path_filter=_BUILD_FILE_FILTER,
231    packages=('nanopb',),
232    gn_args=dict(
233        dir_pw_third_party_nanopb=lambda ctx: '"{}"'.format(
234            ctx.package_root / 'nanopb'
235        ),
236        pw_C_OPTIMIZATION_LEVELS=_OPTIMIZATION_LEVELS,
237    ),
238    ninja_targets=(
239        *_at_all_optimization_levels('stm32f429i'),
240        *_at_all_optimization_levels('host_clang'),
241    ),
242)
243
244gn_crypto_mbedtls_build = build.GnGenNinja(
245    name='gn_crypto_mbedtls_build',
246    path_filter=_BUILD_FILE_FILTER,
247    packages=('mbedtls',),
248    gn_args={
249        'dir_pw_third_party_mbedtls': lambda ctx: '"{}"'.format(
250            ctx.package_root / 'mbedtls'
251        ),
252        'pw_crypto_SHA256_BACKEND': lambda ctx: '"{}"'.format(
253            ctx.root / 'pw_crypto:sha256_mbedtls'
254        ),
255        'pw_crypto_ECDSA_BACKEND': lambda ctx: '"{}"'.format(
256            ctx.root / 'pw_crypto:ecdsa_mbedtls'
257        ),
258        'pw_C_OPTIMIZATION_LEVELS': _OPTIMIZATION_LEVELS,
259    },
260    ninja_targets=(
261        *_at_all_optimization_levels(f'host_{_HOST_COMPILER}'),
262        # TODO(b/240982565): SocketStream currently requires Linux.
263        *(('integration_tests',) if sys.platform.startswith('linux') else ()),
264    ),
265)
266
267gn_crypto_boringssl_build = build.GnGenNinja(
268    name='gn_crypto_boringssl_build',
269    path_filter=_BUILD_FILE_FILTER,
270    packages=('boringssl',),
271    gn_args={
272        'dir_pw_third_party_boringssl': lambda ctx: '"{}"'.format(
273            ctx.package_root / 'boringssl'
274        ),
275        'pw_crypto_SHA256_BACKEND': lambda ctx: '"{}"'.format(
276            ctx.root / 'pw_crypto:sha256_boringssl'
277        ),
278        'pw_crypto_ECDSA_BACKEND': lambda ctx: '"{}"'.format(
279            ctx.root / 'pw_crypto:ecdsa_boringssl'
280        ),
281        'pw_C_OPTIMIZATION_LEVELS': _OPTIMIZATION_LEVELS,
282    },
283    ninja_targets=(
284        *_at_all_optimization_levels(f'host_{_HOST_COMPILER}'),
285        # TODO(b/240982565): SocketStream currently requires Linux.
286        *(('integration_tests',) if sys.platform.startswith('linux') else ()),
287    ),
288)
289
290gn_crypto_micro_ecc_build = build.GnGenNinja(
291    name='gn_crypto_micro_ecc_build',
292    path_filter=_BUILD_FILE_FILTER,
293    packages=('micro-ecc',),
294    gn_args={
295        'dir_pw_third_party_micro_ecc': lambda ctx: '"{}"'.format(
296            ctx.package_root / 'micro-ecc'
297        ),
298        'pw_crypto_ECDSA_BACKEND': lambda ctx: '"{}"'.format(
299            ctx.root / 'pw_crypto:ecdsa_uecc'
300        ),
301        'pw_C_OPTIMIZATION_LEVELS': _OPTIMIZATION_LEVELS,
302    },
303    ninja_targets=(
304        *_at_all_optimization_levels(f'host_{_HOST_COMPILER}'),
305        # TODO(b/240982565): SocketStream currently requires Linux.
306        *(('integration_tests',) if sys.platform.startswith('linux') else ()),
307    ),
308)
309
310gn_teensy_build = build.GnGenNinja(
311    name='gn_teensy_build',
312    path_filter=_BUILD_FILE_FILTER,
313    packages=('teensy',),
314    gn_args={
315        'pw_arduino_build_CORE_PATH': lambda ctx: '"{}"'.format(
316            str(ctx.package_root)
317        ),
318        'pw_arduino_build_CORE_NAME': 'teensy',
319        'pw_arduino_build_PACKAGE_NAME': 'teensy/avr',
320        'pw_arduino_build_BOARD': 'teensy40',
321        'pw_C_OPTIMIZATION_LEVELS': _OPTIMIZATION_LEVELS,
322    },
323    ninja_targets=_at_all_optimization_levels('arduino'),
324)
325
326gn_pico_build = build.GnGenNinja(
327    name='gn_pico_build',
328    path_filter=_BUILD_FILE_FILTER,
329    packages=('pico_sdk',),
330    gn_args={
331        'PICO_SRC_DIR': lambda ctx: '"{}"'.format(
332            str(ctx.package_root / 'pico_sdk')
333        ),
334        'pw_C_OPTIMIZATION_LEVELS': _OPTIMIZATION_LEVELS,
335    },
336    ninja_targets=('pi_pico',),
337)
338
339
340gn_software_update_build = build.GnGenNinja(
341    name='gn_software_update_build',
342    path_filter=_BUILD_FILE_FILTER,
343    packages=('nanopb', 'protobuf', 'mbedtls', 'micro-ecc'),
344    gn_args={
345        'dir_pw_third_party_protobuf': lambda ctx: '"{}"'.format(
346            ctx.package_root / 'protobuf'
347        ),
348        'dir_pw_third_party_nanopb': lambda ctx: '"{}"'.format(
349            ctx.package_root / 'nanopb'
350        ),
351        'dir_pw_third_party_micro_ecc': lambda ctx: '"{}"'.format(
352            ctx.package_root / 'micro-ecc'
353        ),
354        'pw_crypto_ECDSA_BACKEND': lambda ctx: '"{}"'.format(
355            ctx.root / 'pw_crypto:ecdsa_uecc'
356        ),
357        'dir_pw_third_party_mbedtls': lambda ctx: '"{}"'.format(
358            ctx.package_root / 'mbedtls'
359        ),
360        'pw_crypto_SHA256_BACKEND': lambda ctx: '"{}"'.format(
361            ctx.root / 'pw_crypto:sha256_mbedtls'
362        ),
363        'pw_C_OPTIMIZATION_LEVELS': _OPTIMIZATION_LEVELS,
364    },
365    ninja_targets=_at_all_optimization_levels('host_clang'),
366)
367
368gn_pw_system_demo_build = build.GnGenNinja(
369    name='gn_pw_system_demo_build',
370    path_filter=_BUILD_FILE_FILTER,
371    packages=('freertos', 'nanopb', 'stm32cube_f4', 'pico_sdk'),
372    gn_args={
373        'dir_pw_third_party_freertos': lambda ctx: '"{}"'.format(
374            ctx.package_root / 'freertos'
375        ),
376        'dir_pw_third_party_nanopb': lambda ctx: '"{}"'.format(
377            ctx.package_root / 'nanopb'
378        ),
379        'dir_pw_third_party_stm32cube_f4': lambda ctx: '"{}"'.format(
380            ctx.package_root / 'stm32cube_f4'
381        ),
382        'PICO_SRC_DIR': lambda ctx: '"{}"'.format(
383            str(ctx.package_root / 'pico_sdk')
384        ),
385    },
386    ninja_targets=('pw_system_demo',),
387)
388
389gn_docs_build = build.GnGenNinja(name='gn_docs_build', ninja_targets=('docs',))
390
391gn_host_tools = build.GnGenNinja(
392    name='gn_host_tools',
393    ninja_targets=('host_tools',),
394)
395
396
397def _run_cmake(ctx: PresubmitContext, toolchain='host_clang') -> None:
398    build.install_package(ctx, 'nanopb')
399
400    env = None
401    if 'clang' in toolchain:
402        env = build.env_with_clang_vars()
403
404    toolchain_path = ctx.root / 'pw_toolchain' / toolchain / 'toolchain.cmake'
405    build.cmake(
406        ctx,
407        f'-DCMAKE_TOOLCHAIN_FILE={toolchain_path}',
408        '-DCMAKE_EXPORT_COMPILE_COMMANDS=1',
409        f'-Ddir_pw_third_party_nanopb={ctx.package_root / "nanopb"}',
410        '-Dpw_third_party_nanopb_ADD_SUBDIRECTORY=ON',
411        env=env,
412    )
413
414
415@filter_paths(
416    endswith=(*format_code.C_FORMAT.extensions, '.cmake', 'CMakeLists.txt')
417)
418def cmake_clang(ctx: PresubmitContext):
419    _run_cmake(ctx, toolchain='host_clang')
420    build.ninja(ctx, 'pw_apps', 'pw_run_tests.modules')
421
422
423@filter_paths(
424    endswith=(*format_code.C_FORMAT.extensions, '.cmake', 'CMakeLists.txt')
425)
426def cmake_gcc(ctx: PresubmitContext):
427    _run_cmake(ctx, toolchain='host_gcc')
428    build.ninja(ctx, 'pw_apps', 'pw_run_tests.modules')
429
430
431@filter_paths(
432    endswith=(*format_code.C_FORMAT.extensions, '.bazel', '.bzl', 'BUILD')
433)
434def bazel_test(ctx: PresubmitContext) -> None:
435    """Runs bazel test on the entire repo."""
436    build.bazel(
437        ctx,
438        'test',
439        '--test_output=errors',
440        '--',
441        '//...',
442    )
443
444
445@filter_paths(
446    endswith=(
447        *format_code.C_FORMAT.extensions,
448        '.bazel',
449        '.bzl',
450        '.py',
451        '.rs',
452        'BUILD',
453    )
454)
455def bazel_build(ctx: PresubmitContext) -> None:
456    """Runs Bazel build for each supported platform."""
457    # Build everything with the default flags.
458    build.bazel(
459        ctx,
460        'build',
461        '--',
462        '//...',
463    )
464
465    # Mapping from Bazel platforms to targets which should be built for those
466    # platforms.
467    targets_for_platform = {
468        "//pw_build/platforms:lm3s6965evb": [
469            "//pw_rust/examples/embedded_hello:hello",
470        ],
471        "//pw_build/platforms:microbit": [
472            "//pw_rust/examples/embedded_hello:hello",
473        ],
474    }
475
476    for cxxversion in ('c++17', 'c++20'):
477        # Explicitly build for each supported C++ version.
478        build.bazel(
479            ctx,
480            'build',
481            f"--cxxopt=-std={cxxversion}",
482            '--',
483            '//...',
484        )
485
486        for platform, targets in targets_for_platform.items():
487            build.bazel(
488                ctx,
489                'build',
490                f'--platforms={platform}',
491                f"--cxxopt='-std={cxxversion}'",
492                *targets,
493            )
494
495    # Provide some coverage of the FreeRTOS build.
496    #
497    # This is just a minimal presubmit intended to ensure we don't break what
498    # support we have.
499    #
500    # TODO(b/271465588): Eventually just build the entire repo for this
501    # platform.
502    build.bazel(
503        ctx,
504        'build',
505        # Designated initializers produce a warning-treated-as-error when
506        # compiled with -std=c++17.
507        #
508        # TODO(b/271299438): Remove this.
509        '--copt=-Wno-pedantic',
510        '--platforms=//pw_build/platforms:testonly_freertos',
511        '//pw_sync/...',
512        '//pw_thread/...',
513        '//pw_thread_freertos/...',
514    )
515
516
517def pw_transfer_integration_test(ctx: PresubmitContext) -> None:
518    """Runs the pw_transfer cross-language integration test only.
519
520    This test is not part of the regular bazel build because it's slow and
521    intended to run in CI only.
522    """
523    build.bazel(
524        ctx,
525        'test',
526        '//pw_transfer/integration_test:cross_language_small_test',
527        '//pw_transfer/integration_test:cross_language_medium_read_test',
528        '//pw_transfer/integration_test:cross_language_medium_write_test',
529        '//pw_transfer/integration_test:cross_language_large_read_test',
530        '//pw_transfer/integration_test:cross_language_large_write_test',
531        '//pw_transfer/integration_test:multi_transfer_test',
532        '//pw_transfer/integration_test:expected_errors_test',
533        '//pw_transfer/integration_test:legacy_binaries_test',
534        '--test_output=errors',
535    )
536
537
538#
539# General presubmit checks
540#
541
542
543def _clang_system_include_paths(lang: str) -> List[str]:
544    """Generate default system header paths.
545
546    Returns the list of system include paths used by the host
547    clang installation.
548    """
549    # Dump system include paths with preprocessor verbose.
550    command = [
551        'clang++',
552        '-Xpreprocessor',
553        '-v',
554        '-x',
555        f'{lang}',
556        f'{os.devnull}',
557        '-fsyntax-only',
558    ]
559    process = subprocess.run(
560        command, check=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
561    )
562
563    # Parse the command output to retrieve system include paths.
564    # The paths are listed one per line.
565    output = process.stdout.decode(errors='backslashreplace')
566    include_paths: List[str] = []
567    for line in output.splitlines():
568        path = line.strip()
569        if os.path.exists(path):
570            include_paths.append(f'-isystem{path}')
571
572    return include_paths
573
574
575def edit_compile_commands(
576    in_path: Path, out_path: Path, func: Callable[[str, str, str], str]
577) -> None:
578    """Edit the selected compile command file.
579
580    Calls the input callback on all triplets (file, directory, command) in
581    the input compile commands database. The return value replaces the old
582    compile command in the output database.
583    """
584    with open(in_path) as in_file:
585        compile_commands = json.load(in_file)
586        for item in compile_commands:
587            item['command'] = func(
588                item['file'], item['directory'], item['command']
589            )
590    with open(out_path, 'w') as out_file:
591        json.dump(compile_commands, out_file, indent=2)
592
593
594_EXCLUDE_FROM_COPYRIGHT_NOTICE: Sequence[str] = (
595    # Configuration
596    # keep-sorted: start
597    r'\bDoxyfile$',
598    r'\bPW_PLUGINS$',
599    r'\bconstraint.list$',
600    r'^(?:.+/)?\..+$',
601    # keep-sorted: end
602    # Metadata
603    # keep-sorted: start
604    r'\bAUTHORS$',
605    r'\bLICENSE$',
606    r'\bOWNERS$',
607    r'\bPIGWEED_MODULES$',
608    r'\bgo.(mod|sum)$',
609    r'\bpackage-lock.json$',
610    r'\bpackage.json$',
611    r'\brequirements.txt$',
612    r'\byarn.lock$',
613    r'^docker/tag$',
614    # keep-sorted: end
615    # Data files
616    # keep-sorted: start
617    r'\.bin$',
618    r'\.csv$',
619    r'\.elf$',
620    r'\.gif$',
621    r'\.ico$',
622    r'\.jpg$',
623    r'\.json$',
624    r'\.png$',
625    r'\.svg$',
626    r'\.xml$',
627    # keep-sorted: end
628    # Documentation
629    # keep-sorted: start
630    r'\.md$',
631    r'\.rst$',
632    # keep-sorted: end
633    # Generated protobuf files
634    # keep-sorted: start
635    r'\.pb\.c$',
636    r'\.pb\.h$',
637    r'\_pb2.pyi?$',
638    # keep-sorted: end
639    # Diff/Patch files
640    # keep-sorted: start
641    r'\.diff$',
642    r'\.patch$',
643    # keep-sorted: end
644    # Test data
645    # keep-sorted: start
646    r'\bpw_presubmit/py/test/owners_checks/',
647    # keep-sorted: end
648)
649
650# Regular expression for the copyright comment. "\1" refers to the comment
651# characters and "\2" refers to space after the comment characters, if any.
652# All period characters are escaped using a replace call.
653# pylint: disable=line-too-long
654_COPYRIGHT = re.compile(
655    r"""(#|//|::| \*|)( ?)Copyright 2\d{3} The Pigweed Authors
656\1
657\1\2Licensed under the Apache License, Version 2.0 \(the "License"\); you may not
658\1\2use this file except in compliance with the License. You may obtain a copy of
659\1\2the License at
660\1
661\1(?:\2    |\t)https://www.apache.org/licenses/LICENSE-2.0
662\1
663\1\2Unless required by applicable law or agreed to in writing, software
664\1\2distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
665\1\2WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
666\1\2License for the specific language governing permissions and limitations under
667\1\2the License.
668""".replace(
669        '.', r'\.'
670    ),
671    re.MULTILINE,
672)
673# pylint: enable=line-too-long
674
675_SKIP_LINE_PREFIXES = (
676    '#!',
677    '@echo off',
678    ':<<',
679    '/*',
680    ' * @jest-environment jsdom',
681    ' */',
682    '{#',  # Jinja comment block
683    '# -*- coding: utf-8 -*-',
684    '<!--',
685)
686
687
688def _read_notice_lines(file: TextIO) -> Iterable[str]:
689    lines = iter(file)
690    try:
691        # Read until the first line of the copyright notice.
692        line = next(lines)
693        while line.isspace() or line.startswith(_SKIP_LINE_PREFIXES):
694            line = next(lines)
695
696        yield line
697
698        for _ in range(12):  # The notice is 13 lines; read the remaining 12.
699            yield next(lines)
700    except StopIteration:
701        return
702
703
704@filter_paths(exclude=_EXCLUDE_FROM_COPYRIGHT_NOTICE)
705def copyright_notice(ctx: PresubmitContext):
706    """Checks that the Pigweed copyright notice is present."""
707    errors = []
708
709    for path in ctx.paths:
710        if path.stat().st_size == 0:
711            continue  # Skip empty files
712
713        with path.open() as file:
714            if not _COPYRIGHT.match(''.join(_read_notice_lines(file))):
715                errors.append(path)
716
717    if errors:
718        _LOG.warning(
719            '%s with a missing or incorrect copyright notice:\n%s',
720            plural(errors, 'file'),
721            '\n'.join(str(e) for e in errors),
722        )
723        raise PresubmitFailure
724
725
726@filter_paths(endswith=format_code.CPP_SOURCE_EXTS)
727def source_is_in_cmake_build_warn_only(ctx: PresubmitContext):
728    """Checks that source files are in the CMake build."""
729
730    _run_cmake(ctx)
731    missing = build.check_compile_commands_for_files(
732        ctx.output_dir / 'compile_commands.json',
733        (f for f in ctx.paths if f.suffix in format_code.CPP_SOURCE_EXTS),
734    )
735    if missing:
736        _LOG.warning(
737            'Files missing from CMake:\n%s',
738            '\n'.join(str(f) for f in missing),
739        )
740
741
742def build_env_setup(ctx: PresubmitContext):
743    if 'PW_CARGO_SETUP' not in os.environ:
744        _LOG.warning('Skipping build_env_setup since PW_CARGO_SETUP is not set')
745        return
746
747    tmpl = ctx.root.joinpath('pw_env_setup', 'py', 'pyoxidizer.bzl.tmpl')
748    out = ctx.output_dir.joinpath('pyoxidizer.bzl')
749
750    with open(tmpl, 'r') as ins:
751        cfg = ins.read().replace('${PW_ROOT}', str(ctx.root))
752        with open(out, 'w') as outs:
753            outs.write(cfg)
754
755    call('pyoxidizer', 'build', cwd=ctx.output_dir)
756
757
758def _valid_capitalization(word: str) -> bool:
759    """Checks that the word has a capital letter or is not a regular word."""
760    return bool(
761        any(c.isupper() for c in word)  # Any capitalizatian (iTelephone)
762        or not word.isalpha()  # Non-alphabetical (cool_stuff.exe)
763        or shutil.which(word)
764    )  # Matches an executable (clangd)
765
766
767def commit_message_format(_: PresubmitContext):
768    """Checks that the top commit's message is correctly formatted."""
769    if git_repo.commit_author().endswith('gserviceaccount.com'):
770        return
771
772    lines = git_repo.commit_message().splitlines()
773
774    # Show limits and current commit message in log.
775    _LOG.debug('%-25s%+25s%+22s', 'Line limits', '72|', '72|')
776    for line in lines:
777        _LOG.debug(line)
778
779    if not lines:
780        _LOG.error('The commit message is too short!')
781        raise PresubmitFailure
782
783    # Ignore Gerrit-generated reverts.
784    if (
785        'Revert' in lines[0]
786        and 'This reverts commit ' in git_repo.commit_message()
787        and 'Reason for revert: ' in git_repo.commit_message()
788    ):
789        _LOG.warning('Ignoring apparent Gerrit-generated revert')
790        return
791
792    # Ignore Gerrit-generated relands
793    if (
794        'Reland' in lines[0]
795        and 'This is a reland of ' in git_repo.commit_message()
796        and "Original change's description: " in git_repo.commit_message()
797    ):
798        _LOG.warning('Ignoring apparent Gerrit-generated reland')
799        return
800
801    errors = 0
802
803    if len(lines[0]) > 72:
804        _LOG.warning(
805            "The commit message's first line must be no longer than "
806            '72 characters.'
807        )
808        _LOG.warning(
809            'The first line is %d characters:\n  %s', len(lines[0]), lines[0]
810        )
811        errors += 1
812
813    if lines[0].endswith('.'):
814        _LOG.warning(
815            "The commit message's first line must not end with a period:\n %s",
816            lines[0],
817        )
818        errors += 1
819
820    # Check that the first line matches the expected pattern.
821    match = re.match(
822        r'^(?:[\w*/]+(?:{[\w* ,]+})?[\w*/]*|SEED-\d+): (?P<desc>.+)$', lines[0]
823    )
824    if not match:
825        _LOG.warning('The first line does not match the expected format')
826        _LOG.warning(
827            'Expected:\n\n  module_or_target: The description\n\n'
828            'Found:\n\n  %s\n',
829            lines[0],
830        )
831        errors += 1
832    elif not _valid_capitalization(match.group('desc').split()[0]):
833        _LOG.warning(
834            'The first word after the ":" in the first line ("%s") must be '
835            'capitalized:\n  %s',
836            match.group('desc').split()[0],
837            lines[0],
838        )
839        errors += 1
840
841    if len(lines) > 1 and lines[1]:
842        _LOG.warning("The commit message's second line must be blank.")
843        _LOG.warning(
844            'The second line has %d characters:\n  %s', len(lines[1]), lines[1]
845        )
846        errors += 1
847
848    # Ignore the line length check for Copybara imports so they can include the
849    # commit hash and description for imported commits.
850    if not errors and (
851        'Copybara import' in lines[0]
852        and 'GitOrigin-RevId:' in git_repo.commit_message()
853    ):
854        _LOG.warning('Ignoring Copybara import')
855        return
856
857    # Check that the lines are 72 characters or less.
858    for i, line in enumerate(lines[2:], 3):
859        # Skip any lines that might possibly have a URL, path, or metadata in
860        # them.
861        if any(c in line for c in ':/>'):
862            continue
863
864        # Skip any lines with non-ASCII characters.
865        if not line.isascii():
866            continue
867
868        # Skip any blockquoted lines.
869        if line.startswith('  '):
870            continue
871
872        if len(line) > 72:
873            _LOG.warning(
874                'Commit message lines must be no longer than 72 characters.'
875            )
876            _LOG.warning('Line %d has %d characters:\n  %s', i, len(line), line)
877            errors += 1
878
879    if errors:
880        _LOG.error('Found %s in the commit message', plural(errors, 'error'))
881        raise PresubmitFailure
882
883
884@filter_paths(endswith=(*format_code.C_FORMAT.extensions, '.py'))
885def static_analysis(ctx: PresubmitContext):
886    """Runs all available static analysis tools."""
887    build.gn_gen(ctx)
888    build.ninja(ctx, 'python.lint', 'static_analysis')
889
890
891_EXCLUDE_FROM_TODO_CHECK = (
892    # keep-sorted: start
893    r'.bazelrc$',
894    r'.dockerignore$',
895    r'.gitignore$',
896    r'.pylintrc$',
897    r'\bdocs/build_system.rst',
898    r'\bpw_assert_basic/basic_handler.cc',
899    r'\bpw_assert_basic/public/pw_assert_basic/handler.h',
900    r'\bpw_blob_store/public/pw_blob_store/flat_file_system_entry.h',
901    r'\bpw_build/linker_script.gni',
902    r'\bpw_build/py/pw_build/copy_from_cipd.py',
903    r'\bpw_cpu_exception/basic_handler.cc',
904    r'\bpw_cpu_exception_cortex_m/entry.cc',
905    r'\bpw_cpu_exception_cortex_m/exception_entry_test.cc',
906    r'\bpw_doctor/py/pw_doctor/doctor.py',
907    r'\bpw_env_setup/util.sh',
908    r'\bpw_fuzzer/fuzzer.gni',
909    r'\bpw_i2c/BUILD.gn',
910    r'\bpw_i2c/public/pw_i2c/register_device.h',
911    r'\bpw_kvs/flash_memory.cc',
912    r'\bpw_kvs/key_value_store.cc',
913    r'\bpw_log_basic/log_basic.cc',
914    r'\bpw_package/py/pw_package/packages/chromium_verifier.py',
915    r'\bpw_protobuf/encoder.cc',
916    r'\bpw_rpc/docs.rst',
917    r'\bpw_watch/py/pw_watch/watch.py',
918    r'\btargets/mimxrt595_evk/BUILD.bazel',
919    r'\btargets/stm32f429i_disc1/boot.cc',
920    r'\bthird_party/chromium_verifier/BUILD.gn',
921    # keep-sorted: end
922)
923
924
925@filter_paths(exclude=_EXCLUDE_FROM_TODO_CHECK)
926def todo_check_with_exceptions(ctx: PresubmitContext):
927    """Check that non-legacy TODO lines are valid."""  # todo-check: ignore
928    todo_check.create(todo_check.BUGS_OR_USERNAMES)(ctx)
929
930
931@format_code.OWNERS_CODE_FORMAT.filter.apply_to_check()
932def owners_lint_checks(ctx: PresubmitContext):
933    """Runs OWNERS linter."""
934    owners_checks.presubmit_check(ctx.paths)
935
936
937SOURCE_FILES_FILTER = presubmit.FileFilter(
938    endswith=_BUILD_FILE_FILTER.endswith,
939    suffix=('.bazel', '.bzl', '.gn', '.gni', *_BUILD_FILE_FILTER.suffix),
940    exclude=(
941        r'zephyr.*',
942        r'android.*',
943        r'\.black.toml',
944        r'pyproject.toml',
945    ),
946)
947
948
949#
950# Presubmit check programs
951#
952
953OTHER_CHECKS = (
954    # keep-sorted: start
955    # TODO(b/235277910): Enable all Bazel tests when they're fixed.
956    bazel_test,
957    build.gn_gen_check,
958    cmake_clang,
959    cmake_gcc,
960    gitmodules.create(),
961    gn_clang_build,
962    gn_combined_build_check,
963    module_owners.presubmit_check(),
964    npm_presubmit.npm_test,
965    pw_transfer_integration_test,
966    # TODO(hepler): Many files are missing from the CMake build. Add this check
967    # to lintformat when the missing files are fixed.
968    source_in_build.cmake(SOURCE_FILES_FILTER, _run_cmake),
969    static_analysis,
970    stm32f429i,
971    todo_check.create(todo_check.BUGS_OR_USERNAMES),
972    # keep-sorted: end
973)
974
975# The misc program differs from other_checks in that checks in the misc
976# program block CQ on Linux.
977MISC = (
978    # keep-sorted: start
979    gn_emboss_build,
980    gn_nanopb_build,
981    gn_pico_build,
982    gn_pw_system_demo_build,
983    gn_teensy_build,
984    # keep-sorted: end
985)
986
987SANITIZERS = (cpp_checks.all_sanitizers(),)
988
989SECURITY = (
990    # keep-sorted: start
991    gn_crypto_boringssl_build,
992    gn_crypto_mbedtls_build,
993    gn_crypto_micro_ecc_build,
994    gn_software_update_build,
995    # keep-sorted: end
996)
997
998# Avoid running all checks on specific paths.
999PATH_EXCLUSIONS = (re.compile(r'\bthird_party/fuchsia/repo/'),)
1000
1001_LINTFORMAT = (
1002    commit_message_format,
1003    copyright_notice,
1004    format_code.presubmit_checks(),
1005    inclusive_language.presubmit_check.with_filter(
1006        exclude=(
1007            r'\byarn.lock$',
1008            r'\bpackage-lock.json$',
1009        )
1010    ),
1011    cpp_checks.pragma_once,
1012    build.bazel_lint,
1013    owners_lint_checks,
1014    source_in_build.gn(SOURCE_FILES_FILTER),
1015    source_is_in_cmake_build_warn_only,
1016    shell_checks.shellcheck if shutil.which('shellcheck') else (),
1017    keep_sorted.presubmit_check,
1018    todo_check_with_exceptions,
1019)
1020
1021LINTFORMAT = (
1022    _LINTFORMAT,
1023    # This check is excluded from _LINTFORMAT because it's not quick: it issues
1024    # a bazel query that pulls in all of Pigweed's external dependencies
1025    # (https://stackoverflow.com/q/71024130/1224002). These are cached, but
1026    # after a roll it can be quite slow.
1027    source_in_build.bazel(SOURCE_FILES_FILTER),
1028    pw_presubmit.python_checks.check_python_versions,
1029    pw_presubmit.python_checks.gn_python_lint,
1030)
1031
1032QUICK = (
1033    _LINTFORMAT,
1034    gn_quick_build_check,
1035    # TODO(b/34884583): Re-enable CMake and Bazel for Mac after we have fixed
1036    # the clang issues. The problem is that all clang++ invocations need the
1037    # two extra flags: "-nostdc++" and "${clang_prefix}/../lib/libc++.a".
1038    cmake_clang if sys.platform != 'darwin' else (),
1039)
1040
1041FULL = (
1042    _LINTFORMAT,
1043    gn_combined_build_check,
1044    gn_host_tools,
1045    bazel_test if sys.platform == 'linux' else (),
1046    bazel_build if sys.platform == 'linux' else (),
1047    python_checks.gn_python_check,
1048    python_checks.gn_python_test_coverage,
1049    build_env_setup,
1050    # Skip gn_teensy_build if running on Windows. The Teensycore installer is
1051    # an exe that requires an admin role.
1052    gn_teensy_build if sys.platform in ['linux', 'darwin'] else (),
1053)
1054
1055PROGRAMS = Programs(
1056    # keep-sorted: start
1057    full=FULL,
1058    lintformat=LINTFORMAT,
1059    misc=MISC,
1060    other_checks=OTHER_CHECKS,
1061    quick=QUICK,
1062    sanitizers=SANITIZERS,
1063    security=SECURITY,
1064    # keep-sorted: end
1065)
1066
1067
1068def parse_args() -> argparse.Namespace:
1069    """Creates an argument parser and parses arguments."""
1070
1071    parser = argparse.ArgumentParser(description=__doc__)
1072    cli.add_arguments(parser, PROGRAMS, 'quick')
1073    parser.add_argument(
1074        '--install',
1075        action='store_true',
1076        help='Install the presubmit as a Git pre-push hook and exit.',
1077    )
1078
1079    return parser.parse_args()
1080
1081
1082def run(install: bool, exclude: list, **presubmit_args) -> int:
1083    """Entry point for presubmit."""
1084
1085    if install:
1086        install_git_hook(
1087            'pre-push',
1088            [
1089                'python',
1090                '-m',
1091                'pw_presubmit.pigweed_presubmit',
1092                '--base',
1093                'origin/main..HEAD',
1094                '--program',
1095                'quick',
1096            ],
1097        )
1098        return 0
1099
1100    exclude.extend(PATH_EXCLUSIONS)
1101    return cli.run(exclude=exclude, **presubmit_args)
1102
1103
1104def main() -> int:
1105    """Run the presubmit for the Pigweed repository."""
1106    return run(**vars(parse_args()))
1107
1108
1109if __name__ == '__main__':
1110    try:
1111        # If pw_cli is available, use it to initialize logs.
1112        from pw_cli import log
1113
1114        log.install(logging.INFO)
1115    except ImportError:
1116        # If pw_cli isn't available, display log messages like a simple print.
1117        logging.basicConfig(format='%(message)s', level=logging.INFO)
1118
1119    sys.exit(main())
1120