| #!/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()) |