| #!/usr/bin/env python3 | |
| # Copyright (C) 2026 Apple Inc. All rights reserved. | |
| # | |
| # Redistribution and use in source and binary forms, with or without | |
| # modification, are permitted provided that the following conditions | |
| # are met: | |
| # 1. Redistributions of source code must retain the above copyright | |
| # notice, this list of conditions and the following disclaimer. | |
| # 2. Redistributions in binary form must reproduce the above copyright | |
| # notice, this list of conditions and the following disclaimer in the | |
| # documentation and/or other materials provided with the distribution. | |
| # | |
| # THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS "AS IS" AND | |
| # ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED | |
| # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE | |
| # DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR | |
| # ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL | |
| # DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR | |
| # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER | |
| # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, | |
| # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE | |
| # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | |
| """Run the SaferCPP clang static-analyzer checkers against your local changes | |
| without performing a full WebKit build. | |
| On macOS, downloads a recent toolchain from swift.org if needed. On Linux, | |
| uses the clang++ in PATH that implements the most SaferCPP checkers. | |
| Uses CMake for fast generation of compile_commands.json. Or you can supply | |
| your own, or point at a GTK or WPE build-webkit tree with --gtk or --wpe. | |
| """ | |
| import argparse | |
| import concurrent.futures | |
| import os | |
| import re | |
| import shlex | |
| import subprocess | |
| import sys | |
| import tempfile | |
| sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) | |
| from webkitpy.safer_cpp.analyzer import ( # noqa: E402 | |
| HEADER_EXT, SOURCE_EXT, WEBKIT_CHECKERS, WEBKIT_ROOT, CompileDB, add_compile_commands_arguments, | |
| drop_unavailable_checkers, project_for_path, resolve_analyzer_clang, resolve_compile_commands_path, rewrite_argv) | |
| from webkitpy.safer_cpp.checkers import Checker, PROJECTS # noqa: E402 | |
| DIAG_RE = re.compile( | |
| r'^(?P<path>[^:]+):(?P<line>\d+):(?P<col>\d+): ' | |
| r'(?P<kind>warning|error|note): ' | |
| r'(?P<msg>.*?)(?: \[(?P<checker>[\w.]+)\])?$' | |
| ) | |
| SUMMARY_RE = re.compile(r'^\d+ warnings?( and \d+ errors?)? generated\.$') | |
| class Expectations: | |
| def __init__(self): | |
| self._cache = {} | |
| def _load(self, project, shortChecker): | |
| if not Checker.find_checker_by_name(shortChecker) or project not in PROJECTS: | |
| return frozenset() | |
| projectDir = 'WebCore/PAL' if project == 'PAL' else project | |
| path = os.path.join(WEBKIT_ROOT, 'Source', projectDir, | |
| 'SaferCPPExpectations', shortChecker + 'Expectations') | |
| result = set() | |
| try: | |
| with open(path) as f: | |
| for raw in f: | |
| line = raw.strip() | |
| if not line or line.startswith('#') or line.startswith('//'): | |
| continue | |
| if line.startswith('['): | |
| line = line.split(']', 1)[-1].strip() | |
| result.add(line) | |
| except FileNotFoundError: | |
| pass | |
| return frozenset(result) | |
| def isExpected(self, project, shortChecker, relPath): | |
| key = (project, shortChecker) | |
| if key not in self._cache: | |
| self._cache[key] = self._load(project, shortChecker) | |
| return relPath in self._cache[key] | |
| def filter_diagnostics(stderr, requestedPaths, expectations, ignoreExpectations): | |
| lines = stderr.splitlines() | |
| blocks = [] | |
| current = None | |
| for line in lines: | |
| if SUMMARY_RE.match(line): | |
| continue | |
| m = DIAG_RE.match(line) | |
| if m and m.group('kind') in ('warning', 'error'): | |
| current = {'head': m, 'lines': [line]} | |
| blocks.append(current) | |
| elif current is not None: | |
| current['lines'].append(line) | |
| kept = [] | |
| unexpected = 0 | |
| errors = 0 | |
| for block in blocks: | |
| m = block['head'] | |
| kind = m.group('kind') | |
| if kind == 'error': | |
| kept.append('\n'.join(block['lines'])) | |
| errors += 1 | |
| continue | |
| checker = m.group('checker') | |
| if not checker: | |
| continue | |
| diagPath = m.group('path') | |
| diagAbs = os.path.realpath(diagPath) | |
| if diagAbs not in requestedPaths: | |
| continue | |
| if not ignoreExpectations: | |
| short = checker.rsplit('.', 1)[-1] | |
| project, root = project_for_path(diagAbs) | |
| if project: | |
| rel = os.path.relpath(diagAbs, root).replace(os.sep, '/') | |
| if expectations.isExpected(project, short, rel): | |
| continue | |
| kept.append('\n'.join(block['lines'])) | |
| unexpected += 1 | |
| return kept, unexpected, errors | |
| def resolve_base_ref(base): | |
| if base != 'auto': | |
| return base | |
| proc = subprocess.run( | |
| ['git', '-C', WEBKIT_ROOT, 'merge-base', 'origin/main', 'HEAD'], | |
| capture_output=True, text=True) | |
| if proc.returncode == 0 and proc.stdout.strip(): | |
| return proc.stdout.strip() | |
| return 'HEAD' | |
| def collect_input_files(args): | |
| files = [] | |
| for f in args.files: | |
| files.append(os.path.realpath(os.path.join(os.getcwd(), f))) | |
| if args.changed_files: | |
| base = resolve_base_ref(args.base) | |
| for cmd in (['git', '-C', WEBKIT_ROOT, 'diff', '--name-only', '--diff-filter=d', base], | |
| ['git', '-C', WEBKIT_ROOT, 'diff', '--name-only', '--diff-filter=d', '--cached']): | |
| out = subprocess.run(cmd, capture_output=True, text=True, check=True).stdout | |
| for line in out.splitlines(): | |
| line = line.strip() | |
| if line: | |
| files.append(os.path.realpath(os.path.join(WEBKIT_ROOT, line))) | |
| seen = set() | |
| result = [] | |
| for f in files: | |
| ext = os.path.splitext(f)[1] | |
| if ext not in SOURCE_EXT and ext not in HEADER_EXT: | |
| continue | |
| if f in seen: | |
| continue | |
| seen.add(f) | |
| result.append(f) | |
| return result | |
| def map_header_to_tu(headerPath, db): | |
| stem = os.path.splitext(headerPath)[0] | |
| for suffix in ('.cpp', '.mm', 'Cocoa.mm', 'Mac.cpp', 'Mac.mm', '.c', '.m'): | |
| candidate = stem + suffix | |
| if os.path.isfile(candidate) and db.lookup(candidate): | |
| return candidate | |
| return None | |
| def find_flags_entry(headerPath, db): | |
| headerDir = os.path.dirname(headerPath) | |
| _, projectRoot = project_for_path(headerPath) | |
| fallback = None | |
| for path, entry in db.byFile.items(): | |
| if '/unified-sources/UnifiedSource' in path: | |
| continue | |
| if os.path.splitext(path)[1] not in SOURCE_EXT: | |
| continue | |
| if os.path.dirname(path) == headerDir: | |
| return entry | |
| if fallback is None and projectRoot and path.startswith(projectRoot + os.sep): | |
| fallback = entry | |
| return fallback | |
| def write_header_shim(headerPath, entry, tmpDir): | |
| ext = '.mm' if os.path.splitext(entry['file'])[1] in ('.m', '.mm') else '.cpp' | |
| shim = os.path.join(tmpDir, 'analyze-header' + ext) | |
| with open(shim, 'w') as f: | |
| f.write('#include "{}"\n'.format(headerPath)) | |
| return shim | |
| def normalize_checkers(values, parser): | |
| if not values: | |
| return list(WEBKIT_CHECKERS) | |
| result = [] | |
| shortNames = {c.rsplit('.', 1)[-1]: c for c in WEBKIT_CHECKERS} | |
| for v in values: | |
| if v in WEBKIT_CHECKERS: | |
| result.append(v) | |
| elif v in shortNames: | |
| result.append(shortNames[v]) | |
| elif v.startswith(('alpha.webkit.', 'webkit.')): | |
| # A checker the registry does not list yet, such as the borrow checkers. | |
| result.append(v) | |
| else: | |
| parser.error("unknown checker '{}'; valid names: {}, or any full alpha.webkit.* or webkit.* name".format( | |
| v, ', '.join(sorted(shortNames)))) | |
| return result | |
| def repo_relative(path): | |
| try: | |
| rel = os.path.relpath(path, WEBKIT_ROOT) | |
| except ValueError: | |
| return path | |
| return rel if not rel.startswith('..') else path | |
| def parse_args(): | |
| parser = argparse.ArgumentParser( | |
| description=__doc__, | |
| formatter_class=argparse.RawDescriptionHelpFormatter, | |
| usage='%(prog)s [-h] [--base BASE] [--clang CLANG] [--download-toolchain]\n' | |
| ' [--cmake-preset CMAKE_PRESET | --compile-commands PATH | --gtk | --wpe]\n' | |
| ' [--checker NAME] [--ignore-expectations] [-j N] [-v]\n' | |
| ' [--changed-files | FILE ...]', | |
| epilog='examples:\n' | |
| ' analyze-safer-cpp Source/WebCore/dom/Document.cpp\n' | |
| ' analyze-safer-cpp --changed-files -j 12\n' | |
| ' analyze-safer-cpp --wpe --changed-files\n') | |
| parser.add_argument('files', nargs='*', metavar='FILE', | |
| help='source or header files to analyze') | |
| parser.add_argument('--changed-files', action='store_true', | |
| help='analyze files changed vs --base (and staged)') | |
| parser.add_argument('--base', default='auto', | |
| help='git ref to diff against for --changed-files ' | |
| "(default: auto = merge-base of origin/main and HEAD)") | |
| parser.add_argument('--clang', default=None, | |
| help='clang with alpha.webkit.* checkers ' | |
| '(default: newest installed swift.org toolchain on macOS; ' | |
| 'the clang++ in $CXX or PATH with the most checkers on Linux)') | |
| parser.add_argument('--download-toolchain', action='store_true', | |
| help='download and install the latest swift.org development ' | |
| 'snapshot without prompting (macOS only)') | |
| add_compile_commands_arguments(parser) | |
| parser.add_argument('--checker', action='append', dest='checkers', metavar='NAME', | |
| help='restrict to one checker (repeatable; short or full name)') | |
| parser.add_argument('--ignore-expectations', action='store_true', | |
| help='show all checker warnings, ignoring SaferCPPExpectations') | |
| parser.add_argument('-j', type=int, default=os.cpu_count(), metavar='N', | |
| help='analyze N files in parallel (default: number of CPUs)') | |
| parser.add_argument('-v', '--verbose', action='store_true', | |
| help='print each rewritten clang command') | |
| args = parser.parse_args() | |
| if args.files and args.changed_files: | |
| parser.error('specify FILE... or --changed-files, not both') | |
| if not args.files and not args.changed_files: | |
| parser.error('specify FILE... or --changed-files') | |
| args.explicit_checkers = bool(args.checkers) | |
| args.checkers = normalize_checkers(args.checkers, parser) | |
| return args | |
| def main(): | |
| args = parse_args() | |
| db = CompileDB(resolve_compile_commands_path(args)) | |
| clang = resolve_analyzer_clang(args, db) | |
| args.checkers = drop_unavailable_checkers(args.checkers, clang, args.explicit_checkers) | |
| expectations = Expectations() | |
| requested = collect_input_files(args) | |
| if not requested: | |
| print('No analyzable files.') | |
| return 0 | |
| requestedPaths = set(requested) | |
| workItems = [] | |
| seenTUs = set() | |
| for path in requested: | |
| ext = os.path.splitext(path)[1] | |
| header_only = False | |
| flags_entry = None | |
| if ext in HEADER_EXT: | |
| tu = map_header_to_tu(path, db) | |
| if tu: | |
| tu = os.path.realpath(tu) | |
| if args.verbose: | |
| sys.stderr.write('header {} -> {}\n'.format(repo_relative(path), repo_relative(tu))) | |
| requestedPaths.add(tu) | |
| else: | |
| flags_entry = find_flags_entry(path, db) | |
| if not flags_entry: | |
| print('SKIP: {} (no compile command found for project)'.format(repo_relative(path))) | |
| continue | |
| if args.verbose: | |
| sys.stderr.write('header {} -> header-only (flags from {})\n'.format( | |
| repo_relative(path), repo_relative(os.path.realpath(flags_entry['file'])))) | |
| tu = path | |
| header_only = True | |
| else: | |
| tu = path | |
| if tu in seenTUs: | |
| continue | |
| entry = flags_entry if header_only else db.lookup(tu) | |
| if not entry: | |
| print('SKIP: {} (no compile command found; is the build configured?)'.format(repo_relative(tu))) | |
| continue | |
| seenTUs.add(tu) | |
| workItems.append((tu, entry, header_only)) | |
| if not workItems: | |
| print('Nothing to analyze.') | |
| return 0 | |
| def runOne(item): | |
| tu, entry, header_only = item | |
| with tempfile.TemporaryDirectory() as tmpDir: | |
| src = write_header_shim(tu, entry, tmpDir) if header_only else tu | |
| argv, cwd = rewrite_argv(entry, src, clang, args.checkers) | |
| if args.verbose: | |
| sys.stderr.write(' '.join(shlex.quote(a) for a in argv) + '\n') | |
| proc = subprocess.run(argv, cwd=cwd, capture_output=True, text=True) | |
| return tu, proc.returncode, proc.stderr, header_only | |
| totalUnexpected = 0 | |
| totalErrors = 0 | |
| with concurrent.futures.ThreadPoolExecutor(max_workers=max(1, args.j)) as pool: | |
| for tu, returncode, stderr, header_only in pool.map(runOne, workItems): | |
| blocks, unexpected, errors = filter_diagnostics( | |
| stderr, requestedPaths, expectations, args.ignore_expectations) | |
| label = repo_relative(tu) + ' (header-only)' if header_only else repo_relative(tu) | |
| print('==> {}'.format(label)) | |
| if returncode != 0 and not errors: | |
| print(' error: clang exited {}; file did not compile:'.format(returncode)) | |
| for line in stderr.splitlines(): | |
| print(' ' + line) | |
| errors += 1 | |
| elif blocks: | |
| for b in blocks: | |
| print(b) | |
| else: | |
| print(' OK') | |
| totalUnexpected += unexpected | |
| totalErrors += errors | |
| summary = '{} file(s) analyzed, {} unexpected issue(s), {} error(s)'.format( | |
| len(workItems), totalUnexpected, totalErrors) | |
| print() | |
| print(summary) | |
| return 1 if (totalUnexpected or totalErrors) else 0 | |
| if __name__ == '__main__': | |
| sys.exit(main()) |