blob: 6421bbf93cd8c3c7b5e17ee69caa150827506996 [file] [edit]
#!/usr/bin/env python3
"""PVS-Studio over every configuration `.PVS-Studio/matrix.json` names, gated on src/.
A profile is one compile database:
- `board`: `tools/build.py -b <board> <args> --configure-only` into
cmake-build/cmake-build-pvs-<name>, its full example set unless `args` narrows it;
- `base`: a compile-only variant of another profile's database: the src/ entries of its
`example`, each with `-DCFG_TUSB_CONFIG_FILE="<config>"`, the named `defines` replacing the
command's own, `-I` for each of `includes` and the extra compiler `flags`, plus `sources` (src/ files that
example does not compile) cloned from its src/tusb.c entry. Every entry must pass the real compiler's
-fsyntax-only: compile-only evidence, not a firmware build.
Each database is analyzed on its own; diagnostics GA:1-3 whose position is under src/
are the findings. Coverage comes from preprocessing every src/ translation unit of every
profile with its own command: a tracked src/ file is covered when some profile keeps a
non-directive line of it; each `defines` and `expect` macro must have that value in the profile's tusb.c.
`gaps` records what no profile can cover here, with a status (missing-toolchain,
missing-dependency, not-applicable) and a reason; a used gap makes the verdict
`incomplete`, an unexplained uncovered file makes it `failed`.
pvs_studio.py list [--toolchain T] [--shard K/N] [--boards] | --ci-matrix SIZE
pvs_studio.py run [--profile N]... [--toolchain T] [--shard K/N] [--out DIR] [--no-build]
pvs_studio.py verdict DIR... # aggregate the results of several runs (CI legs)
The last stdout line of run/verdict is JSON; exit 0 clean, 1 otherwise.
"""
import argparse
import concurrent.futures
import functools
import json
import os
import re
import shlex
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MATRIX = ROOT / '.PVS-Studio' / 'matrix.json'
RULES = ROOT / '.PVS-Studio' / '.pvsconfig'
sys.path.insert(0, str(ROOT / 'tools'))
import build_utils # noqa: E402
import family_json # noqa: E402
LEVELS = 'GA:1,2,3'
ANALYZE_ARGS = ['--security-related-issues', '--use-old-parser', '-a', 'GA']
LINE_MARK = re.compile(r'^# \d+ "([^"]*)"')
GAP_STATUS = ('missing-toolchain', 'missing-dependency', 'not-applicable')
class Failure(Exception):
pass
def load_matrix(path=MATRIX):
data = json.loads(Path(path).read_text(encoding='utf-8'))
profiles = data.get('profiles', {})
for name, p in profiles.items():
kinds = [k for k in ('board', 'base') if k in p]
if len(kinds) != 1:
raise Failure(f'profile {name}: needs exactly one of board/base')
if 'base' in p:
if p['base'] not in profiles or 'board' not in profiles[p['base']]:
raise Failure(f'profile {name}: base {p["base"]} is not a board profile')
if 'example' not in p:
raise Failure(f'profile {name}: base profile needs an example')
for path_, g in data.get('gaps', {}).items():
if g.get('status') not in GAP_STATUS or not g.get('reason'):
raise Failure(f'gap {path_}: needs status in {GAP_STATUS} and a reason')
return data
def toolchain_of(matrix, name):
p = matrix['profiles'][name]
return p.get('toolchain') or matrix['profiles'].get(p.get('base'), {}).get('toolchain', 'arm-gcc')
@functools.lru_cache(maxsize=None)
def rel(path):
"""Repo-relative posix path of an absolute one, through symlinks; None outside the repo."""
try:
return Path(os.path.realpath(path)).relative_to(ROOT).as_posix()
except ValueError:
return None
def entry_path(e):
return rel(Path(e['directory'], e['file']))
def tusb_entry(entries):
return next((e for e in entries if entry_path(e) == 'src/tusb.c'), None)
def build_dir(name):
return ROOT / 'cmake-build' / f'cmake-build-pvs-{name}'
def build_board(name, p):
"""Configure only, which writes the whole compile database."""
argv = [sys.executable, str(ROOT / 'tools' / 'build.py'), '-b', p['board'], '--build-name', f'pvs-{name}',
'--configure-only', *p.get('args', [])]
r = subprocess.run(argv, cwd=ROOT, capture_output=True, text=True)
if r.returncode != 0:
out = r.stdout + r.stderr
raise Failure(f'build failed: {" ".join(argv[1:])}\n{build_utils.first_error(out) or out[-2000:]}')
def board_entries(name):
root = build_dir(name)
dbs = [d for d in [root / 'compile_commands.json'] + sorted(root.glob('*/*/compile_commands.json')) if d.is_file()]
if not dbs:
raise Failure(f'no compile database under {root}')
out = []
for db in dbs:
out.extend(json.loads(db.read_text(encoding='utf-8')))
return out
def example_of(entry):
"""role/example a cmake entry's object belongs to, '' when it names none."""
obj = entry.get('output')
if not obj:
tokens = family_json.compile_tokens(entry)
obj = next((tokens[i + 1] for i, tok in enumerate(tokens[:-1]) if tok == '-o'), '')
# older CMake writes the object path relative to `directory`
m = re.search(r'/(device|host|dual|typec)/([^/]+)/CMakeFiles/', Path(entry['directory'], obj).as_posix())
return f'{m.group(1)}/{m.group(2)}' if m else ''
def define_name(tok):
return tok[2:].split('=', 1)[0]
def variant_entries(p, entries):
"""`base` profile entries: the example's src/ entries rewritten as the profile names."""
src = [e for e in entries if example_of(e) == p['example'] and (entry_path(e) or '').startswith('src/')]
if not src:
raise Failure(f'example {p["example"]} has no src/ entries in its base database')
defines = dict(p.get('defines', {}))
if 'config' in p:
defines['CFG_TUSB_CONFIG_FILE'] = '"' + str(ROOT / p['config']) + '"'
out = []
for e in src:
kept, seen = [], []
for t in family_json.compile_tokens(e):
if t == '-D':
raise Failure(f'{e["file"]}: split -D form not supported')
if t.startswith('-D') and define_name(t) in defines:
seen.append(define_name(t))
continue
kept.append(t)
twice = sorted({n for n in seen if seen.count(n) > 1})
if twice:
raise Failure(f'{e["file"]}: {twice} defined more than once, replacement is ambiguous')
i = kept.index('-c') if '-c' in kept else len(kept) - 1
kept[i:i] = ([f'-D{k}={v}' for k, v in defines.items()] + [f'-I{ROOT / d}' for d in p.get('includes', [])]
+ p.get('flags', []))
out.append({'directory': e['directory'], 'file': e['file'], 'arguments': kept})
tusb = tusb_entry(out)
for s in p.get('sources', []):
if tusb is None:
raise Failure('no src/tusb.c entry to clone extra sources from')
path = str(ROOT / s)
args = [path if a == tusb['file'] else a for a in tusb['arguments']]
args = [re.sub(r'tusb\.c\.obj$', Path(s).name + '.obj', a) for a in args]
out.append({'directory': tusb['directory'], 'file': path, 'arguments': args})
return out
def own_entries(entries):
"""The entries worth analyzing: this repository's sources outside vendored code, which an
ESP-IDF or SDK database otherwise drowns in thousands of translation units."""
out = []
for e in entries:
f = entry_path(e)
if f and not f.startswith(('lib/', 'hw/mcu/')):
out.append(e)
return out
def run_tool(argv, cwd):
try:
return subprocess.run(argv, capture_output=True, encoding='utf-8', errors='replace', cwd=cwd)
except OSError as ex:
return subprocess.CompletedProcess(argv, 127, '', str(ex))
def syntax_check(entries, jobs):
def one(e):
r = run_tool(family_json.preprocess_argv(e) + ['-fsyntax-only', e['file']], e['directory'])
return None if r.returncode == 0 else f'{rel(e["file"])}: {(r.stderr or r.stdout).strip()[:2000]}'
with concurrent.futures.ThreadPoolExecutor(jobs) as ex:
errs = [x for x in ex.map(one, entries) if x]
if errs:
raise Failure('compile check failed: ' + errs[0])
def kept_lines(stdout, directory):
"""{src file: [kept lines]} from one preprocessor output; a src/ file it includes but keeps
nothing of maps to []."""
out, current = {}, None
for line in stdout.splitlines():
m = LINE_MARK.match(line)
if m:
current = rel(Path(directory, m.group(1)))
if current is not None and not current.startswith('src/'):
current = None
if current:
out.setdefault(current, [])
elif current and line.strip() and not line.lstrip().startswith('#'):
out.setdefault(current, []).append(line)
return out
def coverage(entries, jobs):
"""Lines kept per src/ file over a profile's src/ TUs."""
src = [e for e in entries if (entry_path(e) or '').startswith('src/')]
def one(e):
r = run_tool(family_json.preprocess_argv(e) + ['-E', e['file']], e['directory'])
if r.returncode != 0:
return None, f'preprocess failed: {rel(e["file"])}: {r.stderr.strip()[:1000]}'
return kept_lines(r.stdout, e['directory']), None
counts = {}
with concurrent.futures.ThreadPoolExecutor(jobs) as ex:
for kept, err in ex.map(one, src):
if err:
raise Failure(err)
for f, lines in kept.items():
counts[f] = max(counts.get(f, 0), len(lines))
return counts
def macro_values(entry):
r = run_tool(family_json.preprocess_argv(entry) + ['-dM', '-E', entry['file']], entry['directory'])
if r.returncode != 0:
raise Failure(f'macro dump failed: {r.stderr.strip()[:1000]}')
out = {}
for line in r.stdout.splitlines():
parts = line.split(None, 2)
if len(parts) >= 2 and parts[0] == '#define':
out[parts[1]] = parts[2] if len(parts) == 3 else ''
return out
def check_expect(p, entries):
exp = {**p.get('defines', {}), **p.get('expect', {})}
if not exp:
return
tusb = tusb_entry(entries)
if tusb is None:
raise Failure('expect needs a src/tusb.c entry')
got = macro_values(tusb)
bad = {k: got.get(k) for k, v in exp.items() if got.get(k) != str(v)}
if bad:
raise Failure(f'expected macros differ: {bad}')
def platform_of(matrix, name):
"""The analyzer's data model: ILP32 for the 32-bit (and smaller) MCUs, whose `uint32_t` is an
`unsigned long` the default LP64 model makes 64-bit, LP64 for aarch64."""
return 'linux64' if toolchain_of(matrix, name) == 'aarch64-gcc' else 'linux32'
def analyze(db, out_log, platform):
out_log.unlink(missing_ok=True)
r = run_tool(['pvs-studio-analyzer', 'analyze', '-f', str(db), '-R', str(RULES), '-o', str(out_log),
f'-j{os.cpu_count() or 1}', '--platform', platform, *ANALYZE_ARGS], out_log.parent)
# exit 2 with a written log is the license-expiry notice, not an analysis failure
if r.returncode not in (0, 2) or not out_log.is_file():
raise Failure(f'analyzer exit {r.returncode}: {(r.stdout + r.stderr).strip()[-2000:]}')
def findings(log, out_json):
out_json.unlink(missing_ok=True)
r = run_tool(['plog-converter', '-a', LEVELS, '-t', 'json', '-o', str(out_json), str(log)], ROOT)
if r.returncode != 0 or not out_json.is_file():
raise Failure(f'plog-converter exit {r.returncode}: {(r.stdout + r.stderr).strip()[-1000:]}')
out = []
for w in json.loads(out_json.read_text(encoding='utf-8')).get('warnings', []):
pos = (w.get('positions') or [{}])[0]
f = rel(pos['file']) if pos.get('file') else None
if f and f.startswith('src/'):
out.append({'file': f, 'line': pos.get('line'), 'code': w['code'], 'level': w['level'], 'message': w['message']})
return out
def run_profile(matrix, name, out_dir, build, jobs):
p = matrix['profiles'][name]
res = {'profile': name, 'status': 'ok', 'findings': [], 'kept': {}}
(out_dir / f'{name}.log').unlink(missing_ok=True)
try:
if 'board' in p:
if build:
build_board(name, p)
entries = board_entries(name)
else:
entries = variant_entries(p, board_entries(p['base']))
syntax_check(entries, jobs)
check_expect(p, entries)
db = out_dir / f'{name}.compile_commands.json'
db.write_text(json.dumps(own_entries(entries), indent=1), encoding='utf-8')
res['kept'] = coverage(entries, jobs)
log = out_dir / f'{name}.log'
analyze(db, log, platform_of(matrix, name))
res['findings'] = findings(log, out_dir / f'{name}.json')
except Failure as ex:
res['status'], res['error'] = 'failed', str(ex)
(out_dir / f'{name}.result.json').write_text(json.dumps(res, indent=1), encoding='utf-8')
return res
def macro_only(path):
"""True when a file has nothing but comments and preprocessor directives: its code is
the macros it defines, analyzed where a consumer expands them."""
text = re.sub(r'/\*.*?\*/', ' ', (ROOT / path).read_text(encoding='utf-8', errors='replace'), flags=re.S)
# the extern "C" guards are C++ only and never reach a C translation unit
text = re.sub(r'^[ \t]*#[ \t]*ifdef[ \t]+__cplusplus[ \t]*\n[ \t]*(?:extern[ \t]+"C"[ \t]*\{|\})[ \t]*\n'
r'[ \t]*#[ \t]*endif\b', ' ', text, flags=re.M)
cont = False
for line in text.splitlines():
line = re.sub(r'//.*', '', line).rstrip()
directive = cont or line.lstrip().startswith('#')
cont = directive and line.endswith('\\')
if line.strip() and not directive:
return False
return True
def tracked_sources():
r = subprocess.run(['git', 'ls-files', 'src'], cwd=ROOT, capture_output=True, text=True, check=True)
return sorted(f for f in r.stdout.splitlines() if f.endswith(('.c', '.h')))
def verdict(matrix, results, sources=None):
"""Aggregate per-profile results into the gate's verdict."""
sources = tracked_sources() if sources is None else sources
expected = set(matrix['profiles'])
have = {r['profile']: r for r in results}
missing_profiles = sorted(expected - set(have))
failed = {n: r.get('error', '') for n, r in have.items() if r['status'] != 'ok'}
names = [r['profile'] for r in results]
failed.update({n: 'duplicate profile results' for n in set(names) if names.count(n) > 1})
kept = {}
for r in have.values():
for f, n in r['kept'].items():
kept[f] = max(kept.get(f, 0), n)
gaps = matrix.get('gaps', {})
def covered(f):
return bool(kept.get(f)) or (f in kept and macro_only(f))
uncovered = [f for f in sources if not covered(f)]
used_gaps = {f: gaps[f] for f in uncovered if f in gaps}
unexplained = [f for f in uncovered if f not in gaps]
stale_gaps = sorted(f for f in gaps if f not in sources or covered(f))
uniq = {}
for r in have.values():
for w in r['findings']:
key = (w['file'], w['line'], w['code'], w['message'])
uniq.setdefault(key, dict(w, profiles=[]))['profiles'].append(r['profile'])
found = sorted(uniq.values(), key=lambda w: (w['level'], w['file'], w['line'] or 0, w['code']))
levels = {str(lv): sum(1 for w in found if w['level'] == lv) for lv in (1, 2, 3)}
hard = missing_profiles or failed or unexplained or stale_gaps
# a not-applicable gap has nothing to analyze; only a missing toolchain or dependency leaves analysis undone
missing = any(g['status'] != 'not-applicable' for g in used_gaps.values())
status = 'failed' if hard else ('incomplete' if found or missing else 'clean')
return {'verdict': status, 'findings': levels, 'profiles': len(have), 'missingProfiles': missing_profiles,
'failedProfiles': failed, 'uncovered': unexplained, 'gaps': used_gaps,
'staleGaps': stale_gaps, 'diagnostics': found}
def summary_line(v):
keep = {k: (v[k] if k != 'failedProfiles' else {n: e.splitlines()[0][:300] for n, e in v[k].items()})
for k in v if k != 'diagnostics'}
return json.dumps(keep)
def print_diagnostics(v):
for w in v['diagnostics']:
print(f'L{w["level"]} {w["file"]}:{w["line"]} {w["code"]} {w["message"]} [{",".join(w["profiles"])}]')
def groups(matrix, toolchain=None):
"""Board profiles, each with the base profiles built on its database, in matrix order."""
prof = matrix['profiles']
return [[b] + [n for n, p in prof.items() if p.get('base') == b]
for b, p in prof.items() if 'board' in p and (not toolchain or toolchain_of(matrix, b) == toolchain)]
def select(matrix, toolchain=None, shard=None, names=()):
"""Profiles to run: the named ones with the board profiles they need, else the toolchain's,
optionally the k-th of n round-robin shards of its groups."""
prof = matrix['profiles']
unknown = [n for n in names if n not in prof]
if unknown:
raise Failure(f'unknown profile {unknown}')
if names:
need = set(names) | {prof[n]['base'] for n in names if 'base' in prof[n]}
return [n for g in groups(matrix) for n in g if n in need]
toolchains = {toolchain_of(matrix, n) for n in prof}
if toolchain and toolchain not in toolchains:
raise Failure(f'unknown toolchain {toolchain}, expected one of {sorted(toolchains)}')
gs = groups(matrix, toolchain)
if shard:
k, n = (int(x) for x in shard.split('/'))
if not 1 <= k <= n:
raise Failure(f'bad shard {shard}')
gs = gs[k - 1::n]
if not gs:
raise Failure(f'no profile selected (toolchain {toolchain}, shard {shard})')
return [n for g in gs for n in g]
def ci_matrix(matrix, size):
out = []
for tc in sorted({toolchain_of(matrix, n) for n in matrix['profiles']}):
n = -(-len(groups(matrix, tc)) // size)
out.extend({'toolchain': tc, 'shard': f'{k}/{n}'} for k in range(1, n + 1))
return out
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest='cmd', required=True)
ls = sub.add_parser('list')
ls.add_argument('--toolchain')
ls.add_argument('--shard', help='k/n')
ls.add_argument('--boards', action='store_true', help='print -b <board> per selected board profile')
ls.add_argument('--ci-matrix', type=int, metavar='SIZE', help='JSON legs of at most SIZE board profiles')
rn = sub.add_parser('run')
rn.add_argument('--profile', action='append', default=[])
rn.add_argument('--toolchain')
rn.add_argument('--shard', help='k/n')
rn.add_argument('--out', default=str(ROOT / 'cmake-build' / 'pvs-studio'))
rn.add_argument('--no-build', action='store_true')
rn.add_argument('-j', '--jobs', type=int, default=os.cpu_count() or 1)
vd = sub.add_parser('verdict')
vd.add_argument('dirs', nargs='+')
args = ap.parse_args(argv)
try:
matrix = load_matrix()
if args.cmd == 'list' and args.ci_matrix:
print(json.dumps(ci_matrix(matrix, args.ci_matrix)))
return 0
names = select(matrix, args.toolchain, args.shard, args.profile if args.cmd == 'run' else ()) \
if args.cmd != 'verdict' else []
except (Failure, OSError, ValueError) as ex:
print(json.dumps({'verdict': 'failed', 'error': str(ex)}))
return 1
if args.cmd == 'list':
if args.boards:
print(' '.join(f'-b {matrix["profiles"][n]["board"]}' for n in names if 'board' in matrix['profiles'][n]))
else:
print('\n'.join(f'{n} ({toolchain_of(matrix, n)})' for n in names))
return 0
if args.cmd == 'run':
out = Path(args.out).resolve()
out.mkdir(parents=True, exist_ok=True)
results = []
for n in names:
r = run_profile(matrix, n, out, not args.no_build, args.jobs)
print(f'{n}: {r["status"]} {len(r["findings"])} findings' + (f' - {r["error"].splitlines()[0]}' if r['status'] != 'ok' else ''), file=sys.stderr)
results.append(r)
(out / 'pvs-studio.sarif').unlink(missing_ok=True)
logs = [str(out / f'{n}.log') for n in names if (out / f'{n}.log').is_file()]
sarif_error = None
if logs:
# one SARIF run per invocation: code scanning rejects several runs of one tool and category
r = run_tool(['plog-converter', '-a', LEVELS, '-t', 'sarif', '-o', str(out / 'pvs-studio.sarif'), *logs], ROOT)
if r.returncode != 0 or not (out / 'pvs-studio.sarif').is_file():
sarif_error = f'plog-converter exit {r.returncode}: {(r.stdout + r.stderr).strip()[-1000:]}'
if len(names) < len(matrix['profiles']):
# coverage spans every profile: a partial run reports its findings, the aggregate judges coverage
v = verdict(dict(matrix, profiles={n: matrix['profiles'][n] for n in names}), results)
v = {k: v[k] for k in ('findings', 'profiles', 'failedProfiles', 'diagnostics')}
v['partial'] = True
v['verdict'] = 'failed' if v['failedProfiles'] else ('incomplete' if v['diagnostics'] else 'clean')
else:
v = verdict(matrix, results)
if sarif_error:
v['verdict'], v['sarifError'] = 'failed', sarif_error
else:
results = []
for d in args.dirs:
results.extend(json.loads(p.read_text(encoding='utf-8')) for p in sorted(Path(d).glob('*.result.json')))
v = verdict(matrix, results)
print_diagnostics(v)
print(summary_line(v))
return 0 if v['verdict'] == 'clean' else 1
if __name__ == '__main__':
sys.exit(main())