#!/usr/bin/env python3 """Validate device porting records and render them for the documentation site.""" import argparse import fnmatch from html import escape from pathlib import Path import re import subprocess import sys import tomllib from urllib.parse import quote from urllib.request import urlopen ROOT = Path(__file__).resolve().parents[1] AREAS = { 'configuration': ('Configuration', '配置'), 'firmware': ('Firmware and boot', '固件与启动'), 'cpu': ('CPU and interrupts', 'CPU 与中断'), 'memory': ('Memory and DMA', '内存与 DMA'), 'storage': ('Storage and root', '存储与根卷'), 'display': ('Display', '显示'), 'input': ('Input', '输入'), 'gpu': ('GPU', 'GPU'), 'network': ('Network', '网络'), 'audio': ('Audio', '音频'), 'power': ('Power', '电源'), 'packaging': ('Image assembly', '镜像组装'), 'diagnostics': ('Diagnostics', '诊断'), } FIELDS = { 'inputs': ('Hardware and dependencies', '硬件与依赖'), 'configuration': ('Configuration', '配置'), 'procedure': ('Procedure', '操作步骤'), 'findings': ('Development findings', '开发经验'), 'completion': ('Completion criteria', '完成条件'), 'limits': ('Current limits', '当前范围'), 'next_action': ('Next action', '下一步'), } STATES = {'planned', 'implemented', 'host-verified', 'device-verified', 'not-applicable'} def git(device, *args): return subprocess.check_output(['git', '-C', str(device), *args], text=True).strip() def need(condition, message): if not condition: raise ValueError(message) def text(value): return isinstance(value, str) and bool(value.strip()) def strings(value): return isinstance(value, list) and bool(value) and all(text(item) for item in value) def validate(record, board, label, allow_custom_components=False): need(record.get('schema_version') == 1, f'{label}: schema_version must be 1') info = record.get('device', {}) for key in ('name', 'soc', 'os_profile', 'summary'): need(text(info.get(key)), f'{label}: device.{key} is required') for key in ('name', 'soc', 'os_profile'): need(info[key] == board.get('board', {}).get(key), f'{label}: device.{key} must match board.toml') areas = record.get('area', []) need(isinstance(areas, list), f'{label}: [[area]] is required') ids = [area.get('id') for area in areas] need(len(ids) == len(set(ids)), f'{label}: duplicate area ID') need(set(AREAS).issubset(ids), f'{label}: missing areas: {sorted(set(AREAS) - set(ids))}') selected = set(board.get('components', {}).get('include', [])) catalog = {c['id']: c for c in tomllib.loads((ROOT / 'components.toml').read_text())['component']} pending = list(selected) while pending: component = pending.pop() need(component in catalog or allow_custom_components, f'{label}: unknown component {component}') for dependency in catalog.get(component, {}).get('dependencies', []): if dependency not in selected: selected.add(dependency) pending.append(dependency) covered = set() for area in areas: prefix = f'{label}/{area.get("id")}' need(re.fullmatch(r'[a-z][a-z0-9-]*', area.get('id', '')), f'{prefix}: invalid ID') need(area.get('status') in STATES, f'{prefix}: invalid status') for key in FIELDS: need(strings(area.get(key)), f'{prefix}: {key} requires concrete entries') need(strings(area.get('paths')), f'{prefix}: paths are required') for pattern in area['paths']: need(not pattern.startswith('/') and '..' not in Path(pattern).parts, f'{prefix}: paths must be repository-relative') components = area.get('components') need(isinstance(components, list) and all(text(c) for c in components), f'{prefix}: components must be a list') need(all(re.fullmatch(r'[A-Za-z][A-Za-z0-9_-]*', c) for c in components), f'{prefix}: invalid component ID') covered.update(components) evidence = area.get('evidence', []) need(isinstance(evidence, list), f'{prefix}: evidence must be a list') for item in evidence: need(item.get('kind') in ('source', 'host', 'device'), f'{prefix}: invalid evidence kind') need(text(item.get('reference')) and text(item.get('result')), f'{prefix}: evidence needs reference and result') ref = item['reference'] need(ref.startswith('https://') or (not Path(ref).is_absolute() and '..' not in Path(ref).parts), f'{prefix}: evidence must use HTTPS or a repository-relative path') if item['kind'] in ('host', 'device') or item.get('command'): need(text(item.get('command')), f'{prefix}: verification evidence needs a command') need(re.fullmatch(r'[0-9a-f]{40}', item.get('revision', '')), f'{prefix}: verification evidence needs a commit') if area['status'] == 'implemented': need(bool(evidence), f'{prefix}: implemented work needs evidence') if area['status'] in ('host-verified', 'device-verified'): kind = area['status'].split('-')[0] need(any(e['kind'] == kind for e in evidence), f'{prefix}: {kind} evidence is required') if area['status'] == 'not-applicable': need(not components, f'{prefix}: selected components require an active area') need(selected <= covered, f'{label}: components without an area: {sorted(selected - covered)}') return record def check(device, base=None): record = tomllib.loads((device / 'porting.toml').read_text()) board = tomllib.loads((device / 'board.toml').read_text()) validate(record, board, str(device)) for area in record['area']: for item in area['evidence']: if not item['reference'].startswith('https://'): need((device / item['reference']).is_file(), f'{area["id"]}: evidence file missing: {item["reference"]}') if base and set(base) != {'0'}: old_text = git(device, 'ls-tree', base, 'porting.toml') old = tomllib.loads(git(device, 'show', f'{base}:porting.toml')) if old_text else {} previous = {a['id']: a for a in old.get('area', [])} current = {a['id']: a for a in record['area']} changed = git(device, 'diff', '--name-only', '--no-renames', base, 'HEAD').splitlines() check_changes(previous, current, changed) print(f'{record["device"]["name"]}: {len(record["area"])} structured areas checked') return record def check_changes(previous, current, changed): for path in changed: if path == 'porting.toml' or path.lower().endswith(('.md', '.rst')): continue owners = {key for key, area in current.items() if any( fnmatch.fnmatchcase(path, pattern) for pattern in area['paths'] + previous.get(key, {}).get('paths', []))} need(bool(owners), f'{path}: add its owner area and paths to porting.toml') for owner in owners: before = previous.get(owner, {}) need(any(current[owner][key] != before.get(key) for key in ('findings', 'evidence')), f'{path}: update {owner} findings or evidence with this change') def markdown_text(value): """Render record prose as text in Markdown and Vue templates.""" value = escape(value, quote=False).replace('{', '{').replace('}', '}') return re.sub(r'([\\`*\[\]()#!|_~])', r'\\\1', value).replace('\n', ' ') def render(record, repository, revision, zh=False): locale = int(zh) url = f'https://github.com/{repository}/blob/{revision}' lines = ['
{command}. Revision: `{item["revision"]}`.']
if not area['evidence']:
lines += [('按下一步操作收集证据。' if zh else 'Collect evidence using the next action.')]
return '\n'.join(lines + ['', '