Files
StarPilot/tools/ci/safety_pytest_coverage.py
T
alan-polk 8da0c9d916 Ford: remove the interim BluePilot port; AOL on the stock profiles
StarPilot's angle-only bluepilot_lateral adaptation and the BluePilot-derived curvature strategies (Mach-E,
classic, Explorer, generic CAN FD, new-port curvature, manual-turn latch, curvature preview, pinion
measurement) are removed, with their extended safety profiles 16/32/64 and the interim angle word 128. Ford
lateral is upstream's stock curvature path until the BluePilot lateral library is mounted.

StarPilot's own Ford features stay, re-keyed from the extended words to the stock ones:
- AOL: classic 32 -> 0, Mach-E 18 and the CAN FD trucks 66 -> 2 (+1 with openpilot long); the CAN FD
  retained-display rules apply to word 2. Edge/Mondeo/Transit and the manual bit (256) are unchanged.
- release CAN FD long: word 3 (was 19/67); 19 and 67 are now retired and denied.
- stock cruise switch, CAN FD camera-lead radar handling, hands-free cluster: keyed on the Ford family.
- classic stock ACC (0) keeps the no-ACCDATA TX list the extended classic profile had.

Bits 9-14 of the Ford param are reserved for the BluePilot lateral library's startup bits
(safety/modes/ford_param.h); AOL and pandad mask them out of the profile word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:49:32 -05:00

164 lines
8.3 KiB
Python

"""Run omitted native pytest cases serially against the gate's instrumented library."""
import argparse
import ast
import hashlib
import json
import os
from pathlib import Path
import sys
from unittest import mock
from tools.ci import vehicle_pytest
ROOT = Path(__file__).resolve().parents[2]
# These denial classes belong to the existing mandatory RELEASE vehicle plan.
RELEASE_ONLY = frozenset((
'opendbc_repo/opendbc/safety/tests/test_hyundai_main_rearm.py::TestHkgMainRelease',
'opendbc_repo/opendbc/safety/tests/test_hyundai_classic_cancel_joined.py::TestClassicCancelReleaseDenied',
))
def targets_for_coverage(root=ROOT):
native_root = root / 'opendbc_repo/opendbc/safety/tests'
selected = set()
# unittest already owns its TestCase subclasses, including decorated classes.
for path in sorted(native_root.glob('test_*.py')):
relative = path.relative_to(root).as_posix()
for node in ast.parse(path.read_text(), filename=str(path)).body:
function = isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith('test_')
plain_class = isinstance(node, ast.ClassDef) and node.name.startswith('Test') and not node.bases
if function or plain_class:
selected.add(f'{relative}::{node.name}')
# These existing end-to-end callers bind the supplied library. Other vehicle suites
# deliberately compile distinct DEBUG/RELEASE artifacts and retain their own gates.
for relative in ('openpilot/starpilot/car/nissan/tests/test_aol.py',
'openpilot/starpilot/car/rivian/tests/test_aol_native.py'):
if (root / relative).is_file():
selected.add(relative)
return sorted(selected - RELEASE_ONLY)
class InstrumentedLibraries:
"""Admit only the supplied library and authentic source-matched coverage builds."""
def __init__(self, native, module, root=ROOT):
self.native = Path(native).resolve()
self.module = module
self.root = root
self.original_build = module._build_libsafety
self.original_load = module.load
self.registered = {str(self.native): hashlib.sha256(self.native.read_bytes()).hexdigest()}
self.builds = []
self.source_sha256 = self.source_signature()
def source_signature(self):
files = {}
for relative in ('opendbc_repo/opendbc/safety', 'opendbc_repo/opendbc/bluepilot_lateral'):
for path in sorted((self.root / relative).rglob('*')):
if path.is_file() and path.suffix in ('.h', '.c', '.py'):
files[path.relative_to(self.root).as_posix()] = hashlib.sha256(path.read_bytes()).hexdigest()
if not files:
raise RuntimeError('Missing native coverage source inputs')
return hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest()
def build(self, release=False):
if type(release) is not bool or os.environ.get('SAFETY_COVERAGE') != '1':
raise RuntimeError('Variant builds require genuine coverage instrumentation')
before = self.source_signature()
if before != self.source_sha256:
raise RuntimeError('Native coverage sources changed before a variant build')
path = Path(self.original_build(release=release)).resolve()
if self.source_signature() != before or not path.is_file():
raise RuntimeError('Native coverage sources changed during a variant build')
digest = hashlib.sha256(path.read_bytes()).hexdigest()
if str(path) in self.registered:
raise RuntimeError('Native coverage builder must return a fresh artifact')
self.registered[str(path)] = digest
self.builds.append({'path': str(path), 'sha256': digest, 'source_sha256': before,
'variant': 'release' if release else 'debug', 'instrumented': True})
return str(path)
def load(self, path):
if self.source_signature() != self.source_sha256:
raise RuntimeError('Native coverage sources changed before loading')
path = Path(path).resolve()
expected = self.registered.get(str(path))
if expected is None or not path.is_file() or hashlib.sha256(path.read_bytes()).hexdigest() != expected:
raise RuntimeError('Native coverage cases must use a registered unchanged instrumented library')
return self.original_load(path)
def report_complete(report, targets, native_sha256):
if (not isinstance(report, dict) or type(report.get('schema_version')) is not int or
report.get('schema_version') != 1 or report.get('full_catalog') is not True):
return False
builds = report.get('native_builds')
source = report.get('native_source_sha256')
if (not isinstance(builds, list) or not isinstance(source, str) or len(source) != 64 or
any(char not in '0123456789abcdef' for char in source)):
return False
for row in builds:
if (not isinstance(row, dict) or row.get('instrumented') is not True or row.get('variant') not in ('debug', 'release') or
not isinstance(row.get('path'), str) or not Path(row['path']).is_absolute() or
not all(isinstance(row.get(key), str) and len(row[key]) == 64 and all(char in '0123456789abcdef' for char in row[key])
for key in ('sha256', 'source_sha256')) or row['source_sha256'] != report.get('native_source_sha256')):
return False
if len({row['path'] for row in builds}) != len(builds):
return False
ids, records = report.get('nodeids'), report.get('records')
if (report.get('targets') != targets or not targets or report.get('native_sha256') != native_sha256 or
report.get('native_bound_before') is not True or report.get('native_bound_after') is not True or
report.get('collection_errors') != [] or report.get('execution_errors') != [] or
not isinstance(ids, list) or not ids or not all(type(nodeid) is str for nodeid in ids) or len(set(ids)) != len(ids) or not isinstance(records, list)):
return False
if not all(isinstance(row, dict) and row.get('status') in ('passed', 'skipped') for row in records):
return False
actual = [row.get('id') for row in records]
return (all(type(identity) is str for identity in actual) and len(actual) == len(ids) and len(set(actual)) == len(actual) and
set(actual) == {vehicle_pytest.test_id(nodeid) for nodeid in ids} and any(row['status'] == 'passed' for row in records))
def run(native, output, targets=None):
from opendbc.safety.tests.libsafety import libsafety_py
native = Path(native).resolve()
selected = targets_for_coverage()
full_catalog = targets is None
targets = selected if targets is None else list(targets)
if not targets or any(not any(target == item or target.startswith(item + '::') for item in selected) for target in targets):
raise ValueError('Coverage subset must name existing native catalog cases')
digest = hashlib.sha256(native.read_bytes()).hexdigest()
libsafety_py.load(native)
libraries = InstrumentedLibraries(native, libsafety_py)
report = {'schema_version': 1, 'full_catalog': full_catalog, 'targets': targets, 'native_sha256': digest,
'native_path': str(native), 'native_bound_before': Path(libsafety_py.libpath).resolve() == native,
'nodeids': [], 'records': [], 'collection_errors': [], 'execution_errors': []}
with (mock.patch.object(libsafety_py, 'load', side_effect=libraries.load),
mock.patch.object(libsafety_py, '_build_libsafety', side_effect=libraries.build)):
nodeids, errors = vehicle_pytest.collect(targets)
report.update(nodeids=nodeids, collection_errors=errors)
if not errors:
records, errors = vehicle_pytest.run(nodeids)
report.update(records=records, execution_errors=errors)
report['native_builds'] = libraries.builds
report['native_source_sha256'] = libraries.source_sha256
report['native_bound_after'] = (Path(libsafety_py.libpath).resolve() == native and hashlib.sha256(native.read_bytes()).hexdigest() == digest and
libraries.source_signature() == libraries.source_sha256)
Path(output).write_text(json.dumps(report, indent=2) + '\n')
# The root's bounded native subset is a diagnostic; production always requires the complete catalog.
checked = dict(report, full_catalog=True)
return report_complete(checked, targets, digest)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--native', required=True)
parser.add_argument('--results-json', required=True)
parser.add_argument('--target', action='append')
args = parser.parse_args()
return 0 if run(args.native, args.results_json, args.target) else 1
if __name__ == '__main__':
sys.exit(main())