Files
StarPilot/tools/ci/schema_policy.py
T
firestar5683 adc67c199c Adapt StarPilot hold and Tesla alerts to custom schema companions
Preserve official core schema and bind hold publication to the source plan.
2026-10-06 17:54:49 -05:00

149 lines
7.0 KiB
Python

#!/usr/bin/env python3
"""Enforce cereal's reserved-event contract against the pinned upstream schemas.
See openpilot/cereal/README.md, Custom forks. When syncing upstream, review and
update schema-policy.json alongside upstream-sync.json. Custom fields belong
inside reserved custom.capnp structs; log.capnp may rename only their event
aliases and the reserved raw-data aliases, preserving ordinals and type IDs.
"""
import hashlib
import json
from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[2]
POLICY = Path(__file__).with_name('schema-policy.json')
DECLARATION = re.compile(r'^struct (\w+) @(0x[0-9a-fA-F]+)\s*\{', re.MULTILINE)
SCHEMA_ROOTS = ('openpilot/cereal', 'opendbc_repo/opendbc/car')
def sha256(data):
return hashlib.sha256(data).hexdigest()
def custom_structs(source):
# Mask comments and strings before counting braces; quoted defaults may
# contain braces without ending a message definition.
masked = re.sub(r'"(?:\\.|[^"\\])*"|\#[^\n]*', lambda m: ' ' * len(m[0]), source)
definitions, bodies, spans = {}, {}, []
cursor = 0
for match in DECLARATION.finditer(masked):
if match.start() < cursor:
continue
depth, end = 1, match.end()
while depth and end < len(masked):
depth += (masked[end] == '{') - (masked[end] == '}')
end += 1
if depth:
raise ValueError('Unclosed custom struct')
name, type_id = match.groups()
if type_id.lower() in definitions or name in definitions.values():
raise ValueError('Duplicate custom type ID or name')
definitions[type_id.lower()] = name
bodies[type_id.lower()] = source[match.end():end - 1]
spans.append((match.start(), end))
cursor = end
remainder = source
for start, end in reversed(spans):
remainder = remainder[:start] + remainder[end:]
return definitions, bodies, remainder
def validate(schemas, policy, sync):
errors = []
expected_paths = {*policy['unchanged_schemas'], 'openpilot/cereal/custom.capnp', 'openpilot/cereal/log.capnp'}
for path in sorted(set(schemas) - expected_paths):
errors.append(f'{path}: schema has no explicit policy coverage')
for path in sorted(expected_paths - set(schemas)):
errors.append(f'{path}: policy-covered schema is missing')
if expected_paths - set(schemas):
return errors
dependencies = {entry['path']: entry['commit'] for entry in sync['dependencies']}
if sync['upstream']['commit'] != policy['upstream_commit'] or dependencies.get('opendbc_repo') != policy['opendbc_commit']:
errors.append('Schema policy must be reviewed against the current upstream sync pins')
for path, expected in policy['unchanged_schemas'].items():
reviewed = policy.get('reviewed_enum_additions', {}).get(path)
candidate = schemas[path]
if reviewed:
source = candidate.decode()
pattern = rf'(enum {re.escape(reviewed["enum"])}\s*\{{)([^}}]*)(\}})'
match = re.search(pattern, source)
field = reviewed['field']
if match is None or len(re.findall(rf'(?m)^\s*{re.escape(field)}\s*$', match[2])) != 1:
errors.append(f'{path}: reviewed enum addition is missing or changed')
else:
body = re.sub(rf'(?m)^[ \t]*{re.escape(field)}[ \t]*\n', '', match[2])
source = source[:match.start(2)] + body + source[match.end(2):]
candidate = source.encode()
if sha256(candidate) != expected:
errors.append(f'{path}: upstream-owned schema changed; use custom.capnp reserved structs')
try:
definitions, bodies, remainder = custom_structs(schemas['openpilot/cereal/custom.capnp'].decode())
except (UnicodeDecodeError, ValueError) as error:
return [*errors, f'custom.capnp: {error}']
expected_ids = {slot['type_id'] for slot in policy['reserved']}
if set(definitions) != expected_ids:
errors.append('custom.capnp: reserved type IDs were removed/changed or unreserved top-level types were added')
reserved_by_name = {slot['struct']: slot for slot in policy['reserved']}
for index in policy['historical_empty_slots']:
original_name = f'CustomReserved{index}'
type_id = reserved_by_name[original_name]['type_id']
body = re.sub(r'#[^\n]*', '', bodies.get(type_id, ''))
if definitions.get(type_id) != original_name or body.strip():
errors.append(f'custom.capnp: historical slot {index} must retain its original name and empty body')
for index, restored in policy.get('historical_restored', {}).items():
type_id = restored['type_id']
body = re.sub(r'#[^\n]*', '', bodies.get(type_id, ''))
field = restored['field']
if not re.search(rf'(?m)^\s*{re.escape(field)}\s*$', body):
errors.append(f'custom.capnp: historical slot {index} must preserve {field}')
slot = next((entry for entry in policy['reserved'] if entry['type_id'] == type_id), None)
if slot is None or slot['ordinal'] != restored['ordinal']:
errors.append(f'custom.capnp: historical slot {index} type ID or event ordinal changed')
def canonical(text):
return re.sub(r'\s+', '', re.sub(r'#[^\n]*', '', text))
for restored in policy.get('historical_prefixes', {}).values():
if (definitions.get(restored['type_id']) != restored['name'] or
not canonical(bodies.get(restored['type_id'], '')).startswith(canonical(restored['body']))):
errors.append('custom.capnp: historical occupied prefix changed')
if remainder.strip() != policy['custom_header'].strip():
errors.append('custom.capnp: edits outside the reserved structs')
log = schemas['openpilot/cereal/log.capnp'].decode()
for slot in policy['reserved']:
name = definitions.get(slot['type_id'])
if name is None:
continue
# Changing the name is permitted; changing its type or ordinal is not.
pattern = rf'(?m)^(\s*)\w+ @{slot["ordinal"]} :Custom\.{re.escape(name)};'
log, count = re.subn(pattern, rf'\g<1>{slot["field"]} @{slot["ordinal"]} :Custom.{slot["struct"]};', log)
if count != 1:
errors.append(f'log.capnp: reserved event @{slot["ordinal"]} no longer points to its original custom type ID')
for ordinal, name in policy['raw_reserved'].items():
log, count = re.subn(rf'(?m)^(\s*)\w+ @{ordinal} :Data;', rf'\g<1>{name} @{ordinal} :Data;', log)
if count != 1:
errors.append(f'log.capnp: reserved raw event @{ordinal} changed type or ordinal')
if policy.get('reviewed_log_additions') or policy.get('reviewed_log_ordinal_remaps'):
errors.append('log.capnp: core schema exceptions are not permitted; use custom.capnp reserved structs')
if sha256(log.encode()) != policy['log_sha256']:
errors.append('log.capnp: changes outside the permitted reserved-event aliases')
return errors
def read_inputs(root=ROOT):
policy = json.loads(POLICY.read_text())
paths = {path.relative_to(root).as_posix() for schema_root in SCHEMA_ROOTS for path in (root / schema_root).rglob('*.capnp')}
return {path: (root / path).read_bytes() for path in paths}, policy, json.loads((root / 'upstream-sync.json').read_text())
def main():
errors = validate(*read_inputs())
print(json.dumps({'passed': not errors, 'errors': errors}, indent=2))
return int(bool(errors))
if __name__ == '__main__':
raise SystemExit(main())