blob: 11b6c89d108d55f36a2b7dab2b66c45d0c8eef97 [file]
#!/usr/bin/env python3
"""Driver coverage for membrowse's CI boards and the HIL board pool.
Every dcd_*/hcd_* driver under src/portable (plus ehci/ohci, minus template/)
is checked against two rosters: `.github/ci-pinned-boards.json` and the HIL
rig rosters (test/hil/tinyusb.json, test/hil/hfp.json - which board family,
if any, on the physical rig builds it).
A pinned board covers the drivers its default configure compiles, per its
hw/bsp/family.json row: `cmake.portable` filtered by `cmake.roles` - dcd_*
counts with a device or dual role, hcd_*/ehci/ohci with host or dual. This
trusts family.json, so coverage is only as fresh as the catalog: a
skip.txt/only.txt/example CMakeLists.txt edit shows up at its next refresh.
`uncovered` waives drivers with a reason; a waiver may overlap a driver the
pinned boards compile (e.g. an empty build without its CFG_ option).
Fatal, one line per error to stderr, exit 1: malformed json, a non-object or
board-less or duplicate entry, an unknown board, a pinned board with no
family.json row or a null cmake row, a family no CI toolchain builds
(ci_set_matrix.family_list), a waiver naming no driver source file or with a
blank reason, and a driver neither covered nor waived. Exit 0 otherwise, with
INFO lines on stdout: each waiver with the pinned boards it suppresses, and
drivers with no rig board.
The HIL family mapping reuses tools/ci_select.py's rule-3/4 machinery
(port_families()/port_option_gates()/board_roles(), the same data and role
filter that pick which rig boards a src/portable/ diff selects) rather than
a second heuristic.
"""
import functools
import json
import os
import sys
REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
FAMILY_JSON = os.path.join(REPO, 'hw', 'bsp', 'family.json')
PORTABLE = os.path.join(REPO, 'src', 'portable')
sys.path.insert(0, os.path.join(REPO, 'tools'))
import ci_select # noqa: E402
sys.path.insert(0, os.path.join(REPO, '.github', 'scripts'))
import ci_set_matrix # noqa: E402
def _is_host_driver(d):
return d.startswith('hcd_') or d in ('ehci', 'ohci')
def row_drivers(row):
"""Driver stems a family.json cmake row compiles for a role that uses them."""
roles = set(row['roles'])
out = set()
for src in row['portable']:
stem = os.path.splitext(os.path.basename(src))[0]
if _is_host_driver(stem):
wanted = {'host', 'dual'}
elif stem.startswith('dcd_'):
wanted = {'device', 'dual'}
else:
continue
if roles & wanted:
out.add(stem)
return out
@functools.lru_cache(maxsize=None)
def list_driver_paths(portable_dir):
"""{stem: source path} for every dcd_*/hcd_* driver plus ehci/ohci, template
excluded. A stem collision (two files with the same driver name) silently
keeps whichever os.walk() visits last - the real tree has none today."""
paths = {}
for root, _dirs, files in os.walk(portable_dir):
if os.path.basename(root) == 'template':
continue
for f in files:
stem, ext = os.path.splitext(f)
if ext != '.c':
continue
if stem.startswith(('dcd_', 'hcd_')) or stem in ('ehci', 'ohci'):
paths[stem] = os.path.join(root, f)
return paths
def _port_scope(driver_path, gates_by_port, repo_root):
"""(port, families building it, build options gating it) for one driver source."""
port = _driver_port(driver_path, repo_root)
if not port:
return None, set(), set()
return port, ci_select.port_families(port, repo_root), gates_by_port.get(port, set())
def _load_object(path):
"""(json object, errors): an unreadable, malformed or non-object file is one
fatal line like every other check, not a traceback."""
try:
with open(path) as f:
data = json.load(f)
except (OSError, json.JSONDecodeError) as e:
return None, [f'{path}: {e}']
if not isinstance(data, dict):
return None, [f'{path}: top level must be an object']
return data, []
def _entry_coverage(entry, where, catalog, ci_families):
"""(board or None, driver stems or None, errors) for one roster entry.
Drivers None means the entry contributes no coverage; a board with an error
against it can still contribute (an unbuilt family is not an early exit)."""
if not isinstance(entry, dict):
return None, None, [f'{where}: must be an object, not {type(entry).__name__}']
board = entry.get('board')
# the type check too: a truthy non-string board reaches board_family() as a traceback
if not (isinstance(board, str) and board):
return None, None, [f'{where}: missing "board"' if not board else
f'{where}: "board" must be a string, not {type(board).__name__}']
where = f'{where} ({board})'
family = ci_select.board_family(board, REPO)
if family is None:
return board, None, [f'{where}: unknown board (no hw/bsp/*/boards/{board})']
errors = []
if family not in ci_families:
errors.append(
f'{where}: family "{family}" is pinned but built by no CI toolchain '
f'(not in ci_set_matrix.family_list), so it covers nothing')
rows = catalog.get(family)
if not isinstance(rows, dict) or board not in rows:
return board, None, errors + [f'{where}: no row in hw/bsp/family.json']
row = rows[board]
if not (isinstance(row, dict) and isinstance(row.get('cmake'), dict)):
return board, None, errors + [
f'{where}: hw/bsp/family.json row has no cmake configure']
cmake = row['cmake']
# row_drivers indexes both straight away: a catalog hand-edited between
# family.json's own hook and this one must fail as a line, not a traceback
if not all(isinstance(cmake.get(k), list) and all(isinstance(x, str) for x in cmake[k])
for k in ('roles', 'portable')):
return board, None, errors + [
f'{where}: hw/bsp/family.json cmake row needs string lists '
f'"roles" and "portable"']
return board, row_drivers(cmake), errors
def _waiver_errors(uncovered, drivers):
"""One line per waiver naming no driver source file or carrying a blank reason."""
errors = []
for d, reason in uncovered.items():
if d not in drivers:
errors.append(f'uncovered: "{d}" matches no driver source file')
if not (isinstance(reason, str) and reason.strip()):
errors.append(f'uncovered "{d}": reason must be a non-empty string')
return errors
def pinned_coverage(path, catalog_path=FAMILY_JSON):
"""(errors, {board: driver stems} before waivers, uncovered)."""
data, load_errors = _load_object(path)
if load_errors:
return load_errors, {}, {}
boards = data.get('boards')
uncovered = data.get('uncovered', {})
if not isinstance(boards, list) or not boards:
return [f'{path}: "boards" must be a non-empty list'], {}, {}
if not isinstance(uncovered, dict):
return [f'{path}: "uncovered" must be an object of driver: reason'], {}, {}
catalog, load_errors = _load_object(catalog_path)
if load_errors:
return load_errors, {}, {}
errors = []
drivers = set(list_driver_paths(PORTABLE))
# ci_set_matrix.family_list is the ground truth for which families CI builds at
# all - espressif included, with no toolchain, since hil-build-esp builds it by
# board name rather than as a matrix leg.
ci_families = set(ci_set_matrix.family_list)
coverage = {}
for i, entry in enumerate(boards):
board, board_drivers, entry_errors = _entry_coverage(
entry, f'boards[{i}]', catalog, ci_families)
if board is not None and board in coverage:
errors.append(f'boards[{i}] ({board}): duplicate entry')
continue
errors += entry_errors
if board_drivers is not None:
coverage[board] = board_drivers
errors += _waiver_errors(uncovered, drivers)
covered = set().union(*coverage.values())
for d in sorted(drivers - covered - set(uncovered)):
errors.append(f'membrowse: {d} has no CI board and no uncovered entry')
return errors, coverage, uncovered
def check(path, catalog_path=FAMILY_JSON):
"""Fatal roster validity and undocumented coverage errors, one line each."""
return pinned_coverage(path, catalog_path)[0]
def membrowse_gaps(coverage, uncovered):
"""('INFO', message) per waiver, naming the pinned boards whose compiled
driver it suppresses. Only meaningful once check() passed."""
out = []
for d, reason in sorted(uncovered.items()):
boards = sorted(b for b, drivers in coverage.items() if d in drivers)
which = f'suppresses: {", ".join(boards)}' if boards else 'no candidates'
out.append(('INFO', f'membrowse: {d} uncovered - {reason} ({which})'))
return out
def _driver_port(driver_path, repo_root):
"""Port dir per ci_select's rule-3/4 extraction, e.g. 'synopsys/dwc2' from
'src/portable/synopsys/dwc2/dcd_dwc2.c', or None if it doesn't match (it
always should - DriverScan pins the tree shape ci_select._PORT_PATH_RE
expects)."""
rel = os.path.relpath(driver_path, repo_root).replace(os.sep, '/')
m = ci_select._PORT_PATH_RE.match(rel)
return m.group(1) if m else None
def _hil_roster_boards(repo_root):
boards = {}
for name in ('tinyusb.json', 'hfp.json'):
with open(os.path.join(repo_root, 'test', 'hil', name)) as f:
for board in json.load(f).get('boards', []):
boards.setdefault(board['name'], board)
return list(boards.values())
def hil_gaps(repo_root=REPO):
"""('INFO', message) for every driver with no board on the HIL rig.
A driver is covered when a roster board (a) has the matching role for the
driver - device for dcd_*, host for hcd_*/ehci/ohci, checked against
ci_select.board_roles(), exactly the `board_roles(b) & roles` filter
rule 3/4 applies - and (b) either its family is in the driver's port's
family set (ci_select.port_families - the same family.cmake references
rule 3/4 reads for a src/portable/ diff) or it turns on a build option
that gates the port regardless of family (ci_select.port_option_gates/
board_options - e.g. analog/max3421's MAX3421_HOST)."""
roster_boards = _hil_roster_boards(repo_root)
gates_by_port = ci_select.port_option_gates(repo_root)
driver_paths = list_driver_paths(os.path.join(repo_root, 'src', 'portable'))
out = []
for d in sorted(driver_paths):
_port, fams, gates = _port_scope(driver_paths[d], gates_by_port, repo_root)
# not ci_select._port_roles: bare ehci.c/ohci.c would read as "both" roles there
role = 'host' if _is_host_driver(d) else 'device'
covered = any(
(ci_select.board_family(b['name'], repo_root) in fams or
(gates and ci_select.board_options(b, repo_root) & gates)) and
role in ci_select.board_roles(b)
for b in roster_boards)
if not covered:
out.append(('INFO', f'hil: {d} has no board on the rig'))
return out
def main():
path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
REPO, '.github', 'ci-pinned-boards.json')
catalog_path = sys.argv[2] if len(sys.argv) > 2 else FAMILY_JSON
errors, coverage, uncovered = pinned_coverage(path, catalog_path)
for e in errors:
print(e, file=sys.stderr)
if errors:
return 1
gaps = membrowse_gaps(coverage, uncovered) + hil_gaps()
for level, msg in gaps:
print(f'{level}: {msg}')
return 0
if __name__ == '__main__':
sys.exit(main())