blob: 255c883ad8aacf1092667696554a5948deb04ed2 [file] [edit]
#!/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.
Downloads a recent toolchain from swift.org if needed.
Uses CMake for fast generation of compile_commands.json. Or you can supply
your own.
"""
import argparse
import concurrent.futures
import glob
import json
import os
import re
import shlex
import subprocess
import sys
import tempfile
SCRIPT_DIR = os.path.dirname(os.path.realpath(__file__))
sys.path.insert(0, SCRIPT_DIR)
def find_webkit_root():
proc = subprocess.run(['git', 'rev-parse', '--show-toplevel'],
capture_output=True, text=True)
if proc.returncode == 0 and proc.stdout.strip():
return os.path.realpath(proc.stdout.strip())
return os.path.dirname(os.path.dirname(SCRIPT_DIR))
WEBKIT_ROOT = find_webkit_root()
from webkitpy.safer_cpp.checkers import Checker, PROJECTS # noqa: E402
# Keep in sync with Tools/Scripts/build-and-analyze WEBKIT_CHECKERS.
WEBKIT_CHECKERS = [
'alpha.webkit.ForwardDeclChecker',
'alpha.webkit.MemoryUnsafeCastChecker',
'alpha.webkit.NoDeleteChecker',
'alpha.webkit.NoUncheckedPtrMemberChecker',
'alpha.webkit.NoUnretainedMemberChecker',
'alpha.webkit.RetainPtrCtorAdoptChecker',
'alpha.webkit.UncheckedCallArgsChecker',
'alpha.webkit.UncheckedLocalVarsChecker',
'alpha.webkit.UncountedCallArgsChecker',
'alpha.webkit.UncountedLocalVarsChecker',
'alpha.webkit.UnretainedCallArgsChecker',
'alpha.webkit.UnretainedLambdaCapturesChecker',
'alpha.webkit.UnretainedLocalVarsChecker',
'webkit.NoUncountedMemberChecker',
'webkit.RefCntblBaseVirtualDtor',
'webkit.UncountedLambdaCapturesChecker',
]
# Keep in sync with Tools/Scripts/build-and-analyze make_analyzer_flags().
DISABLE_CATEGORIES = 'alpha,apiModeling,core,cplusplus,deadcode,debug,fuchsia,nullability,optin,osx,security,unix,webkit'
SOURCE_EXT = {'.cpp', '.cc', '.cxx', '.c', '.mm', '.m'}
HEADER_EXT = {'.h', '.hpp', '.hxx'}
DEFAULT_BUILD_DIR = os.path.join(WEBKIT_ROOT, 'WebKitBuild', 'cmake-mac', 'Debug')
TOOLCHAIN_DIRS = [
os.path.expanduser('~/Library/Developer/Toolchains'),
'/Library/Developer/Toolchains',
]
SWIFT_LATEST_URL = 'https://download.swift.org/development/xcode/latest-build.yml'
SWIFT_DOWNLOAD_BASE = 'https://download.swift.org/development/xcode'
# Ordered so that PAL (which lives under WebCore) is matched before WebCore.
PROJECT_ROOTS = [
('PAL', os.path.join(WEBKIT_ROOT, 'Source', 'WebCore', 'PAL')),
('WTF', os.path.join(WEBKIT_ROOT, 'Source', 'WTF')),
] + [(p, os.path.join(WEBKIT_ROOT, 'Source', p)) for p in PROJECTS if p not in ('PAL', 'WTF')]
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\.$')
INCLUDE_RE = re.compile(r'^\s*#\s*include\s+"([^"]+)"')
def project_for_path(absPath):
for name, root in PROJECT_ROOTS:
if absPath.startswith(root + os.sep):
return name, root
return None, None
class CompileDB:
def __init__(self, compileCommandsPath):
with open(compileCommandsPath) as f:
entries = json.load(f)
self.byFile = {os.path.realpath(e['file']): e for e in entries}
self._bundleIndex = None
def _buildBundleIndex(self):
index = {}
for path, entry in self.byFile.items():
if '/unified-sources/UnifiedSource' not in path:
continue
try:
with open(path) as f:
for line in f:
m = INCLUDE_RE.match(line)
if m:
index.setdefault(m.group(1), entry)
except OSError:
pass
self._bundleIndex = index
def lookup(self, absSrc):
absSrc = os.path.realpath(absSrc)
entry = self.byFile.get(absSrc)
if entry:
return entry
if self._bundleIndex is None:
self._buildBundleIndex()
_, root = project_for_path(absSrc)
if not root:
return None
rel = os.path.relpath(absSrc, root).replace(os.sep, '/')
return self._bundleIndex.get(rel)
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 resolve_preset_build_dir(presetName):
presets = {}
for name in ('CMakePresets.json', 'CMakeUserPresets.json'):
path = os.path.join(WEBKIT_ROOT, name)
if not os.path.isfile(path):
continue
with open(path) as f:
for p in json.load(f).get('configurePresets', []):
presets[p['name']] = p
def findBinaryDir(name, seen):
if name in seen or name not in presets:
return None
seen.add(name)
p = presets[name]
if 'binaryDir' in p:
return p['binaryDir']
parents = p.get('inherits', [])
if isinstance(parents, str):
parents = [parents]
for parent in parents:
result = findBinaryDir(parent, seen)
if result:
return result
return None
if presetName not in presets:
sys.exit("error: unknown preset '{}'".format(presetName))
binaryDir = findBinaryDir(presetName, set())
if not binaryDir:
sys.exit("error: preset '{}' does not define binaryDir".format(presetName))
binaryDir = binaryDir.replace('${sourceDir}', WEBKIT_ROOT)
if not os.path.isabs(binaryDir):
binaryDir = os.path.join(WEBKIT_ROOT, binaryDir)
return binaryDir
def rewrite_argv(entry, absSrc, clang, checkers, header_only=False):
tokens = shlex.split(entry['command'])
out = [clang]
i = 1
n = len(tokens)
entryFile = entry['file']
entryFileReal = os.path.realpath(entryFile)
while i < n:
t = tokens[i]
if t == '-o' or t == '-MT' or t == '-MF' or t == '-MQ' or t == '-include':
i += 2
continue
if t == '-x' and i + 1 < n:
i += 2
continue
if t in ('-c', '-MD', '-MMD', '-Winvalid-pch', '-fpch-instantiate-templates',
'-Werror', '-fcolor-diagnostics'):
i += 1
continue
if t.startswith('-fdiagnostics-color') or t.startswith('-Werror='):
i += 1
continue
if t == '-Xclang' and i + 1 < n:
nxt = tokens[i + 1]
if nxt in ('-include-pch', '-include'):
i += 4
continue
if nxt == '-fno-pch-timestamp':
i += 2
continue
if t == entryFile or os.path.realpath(t) == entryFileReal:
i += 1
continue
out.append(t)
i += 1
if header_only:
out += ['-x', 'c++-header']
out += [
absSrc,
'--analyze',
'-fno-color-diagnostics',
'-Wno-error',
# The SaferCPP EWS analyzes a Release build. Disable assertions so a
# Debug compile_commands.json produces the same checker results.
'-UASSERT_ENABLED', '-DASSERT_ENABLED=0', '-DNDEBUG=1',
'-DRELEASE_WITHOUT_OPTIMIZATIONS=1',
'-Xclang', '-analyzer-output=text',
'-Xclang', '-analyzer-disable-checker', '-Xclang', DISABLE_CATEGORIES,
'-Xclang', '-analyzer-checker', '-Xclang', ','.join(checkers),
'-Xclang', '-analyzer-config', '-Xclang', 'max-nodes=10000000',
'-o', os.devnull,
]
return out, entry['directory']
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.dirname(path) == headerDir:
return entry
if fallback is None and projectRoot and path.startswith(projectRoot + os.sep):
fallback = entry
return fallback
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])
else:
parser.error("unknown checker '{}'; valid names: {}".format(
v, ', '.join(sorted(shortNames))))
return result
def read_plist_key(plist, key):
proc = subprocess.run(['plutil', '-extract', key, 'raw', '-o', '-', plist],
capture_output=True, text=True)
return proc.stdout.strip() if proc.returncode == 0 else ''
def find_toolchain_clangs():
candidates = []
for d in TOOLCHAIN_DIRS:
for tc in glob.glob(os.path.join(d, '*.xctoolchain')):
clang = os.path.join(tc, 'usr', 'bin', 'clang++')
if not os.path.isfile(clang):
continue
plist = os.path.join(tc, 'Info.plist')
created = read_plist_key(plist, 'CreatedDate')
display = read_plist_key(plist, 'DisplayName') or os.path.basename(tc)
candidates.append((created, clang, display))
candidates.sort(reverse=True)
return candidates
def download_swift_toolchain():
print('Fetching latest swift.org development snapshot manifest...', file=sys.stderr)
proc = subprocess.run(['curl', '-fsSL', SWIFT_LATEST_URL], capture_output=True, text=True)
if proc.returncode != 0:
sys.exit('error: failed to fetch {}: {}'.format(SWIFT_LATEST_URL, proc.stderr.strip()))
fields = dict(re.findall(r'^(\w+):\s*(\S+)', proc.stdout, re.M))
snapshot = fields.get('dir')
pkgName = fields.get('download')
if not snapshot or not pkgName:
sys.exit('error: could not parse snapshot manifest:\n' + proc.stdout)
url = '{}/{}/{}'.format(SWIFT_DOWNLOAD_BASE, snapshot, pkgName)
with tempfile.NamedTemporaryFile(suffix='.pkg', delete=False) as f:
pkgPath = f.name
print('Downloading {} (~1.5 GB)...'.format(url), file=sys.stderr)
rc = subprocess.call(['curl', '-fL', '-#', '-o', pkgPath, url])
if rc != 0:
sys.exit('error: download failed')
print('Installing to ~/Library/Developer/Toolchains/ ...', file=sys.stderr)
rc = subprocess.call(['installer', '-pkg', pkgPath, '-target', 'CurrentUserHomeDirectory'])
os.unlink(pkgPath)
if rc != 0:
sys.exit('error: installer failed (exit {})'.format(rc))
expected = os.path.join(TOOLCHAIN_DIRS[0], snapshot + '.xctoolchain', 'usr', 'bin', 'clang++')
if os.path.isfile(expected):
return expected, snapshot
for _, clang, display in find_toolchain_clangs():
if snapshot in clang:
return clang, display
sys.exit('error: installed toolchain not found at {}'.format(expected))
def resolve_analyzer_clang(args):
if args.clang:
if not os.path.isfile(args.clang):
sys.exit("error: --clang path '{}' does not exist".format(args.clang))
print('Using analyzer: {}'.format(args.clang), file=sys.stderr)
return args.clang
if args.download_toolchain:
clang, display = download_swift_toolchain()
print('Using analyzer: {} ({})'.format(clang, display), file=sys.stderr)
return clang
candidates = find_toolchain_clangs()
if candidates:
_, clang, display = candidates[0]
print('Using analyzer: {} ({})'.format(clang, display), file=sys.stderr)
return clang
print(file=sys.stderr)
print('No Swift toolchain found in {}.'.format(' or '.join(TOOLCHAIN_DIRS)), file=sys.stderr)
print(file=sys.stderr)
if not sys.stdin.isatty():
sys.exit('Pass --clang PATH, or --download-toolchain to fetch a swift.org snapshot.')
try:
answer = input('`analyze-safer-cpp` requires an up-to-date toolchain. Download the latest swift.org development snapshot (~1.5 GB)? [Y/n] ')
except (EOFError, KeyboardInterrupt):
answer = ''
if answer.strip().lower() in ('y', 'yes', ''):
clang, display = download_swift_toolchain()
print('Using analyzer: {} ({})'.format(clang, display), file=sys.stderr)
return clang
sys.exit('No analyzer clang available. Pass --clang PATH or re-run with --download-toolchain.')
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]\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')
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)')
parser.add_argument('--download-toolchain', action='store_true',
help='download and install the latest swift.org development '
'snapshot without prompting')
where = parser.add_mutually_exclusive_group()
where.add_argument('--cmake-preset', help='CMake configure preset name')
where.add_argument('--compile-commands', metavar='PATH',
help='path to a compile_commands.json file')
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.checkers = normalize_checkers(args.checkers, parser)
return args
def main():
args = parse_args()
clang = resolve_analyzer_clang(args)
if args.compile_commands:
compileCommandsPath = os.path.abspath(args.compile_commands)
elif args.cmake_preset:
compileCommandsPath = os.path.join(resolve_preset_build_dir(args.cmake_preset), 'compile_commands.json')
else:
compileCommandsPath = os.path.join(DEFAULT_BUILD_DIR, 'compile_commands.json')
if not os.path.isfile(compileCommandsPath):
sys.exit("error: compile_commands.json not found at '{}' (run cmake --preset ... first)".format(compileCommandsPath))
db = CompileDB(compileCommandsPath)
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
argv, cwd = rewrite_argv(entry, tu, clang, args.checkers, header_only)
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())