mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 13:33:42 +08:00
b06f37e2c7
Define persistent settings, typed schema fields and storage migrations before feature owners consume them. Keep calibration and parameter readers aligned with the shared data contracts.
127 lines
5.8 KiB
Python
127 lines
5.8 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():
|
|
if sha256(schemas[path]) != 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')
|
|
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 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())
|