From 7c03e8239effdbb2c8a99510296d7119fa70b060 Mon Sep 17 00:00:00 2001 From: hathach Date: Thu, 5 Nov 2026 18:10:32 +0700 Subject: [PATCH 1/9] Add agent build and HIL contracts --- .agents | 1 + .claude/skills/build/SKILL.md | 77 +++ .claude/skills/build/scripts/check_build.py | 202 ++++++++ .claude/skills/hil-contract/SKILL.md | 124 +++++ .../hil-contract/scripts/agentrc_pin.py | 290 ++++++++++++ .../skills/hil-contract/scripts/hil_remote.py | 50 ++ .../skills/hil-contract/scripts/hil_test.py | 237 ++++++++++ .claude/test/test_agentrc_pin.py | 439 ++++++++++++++++++ .claude/test/test_build.py | 193 ++++++++ .claude/test/test_hil.py | 169 +++++++ .claude/test/test_hil_remote.py | 40 ++ .github/workflows/githubci.yml | 11 + .gitignore | 5 + .pre-commit-config.yaml | 19 + AGENTS.md | 73 +-- CLAUDE.md | 69 +++ test/hil/agentrc.json | 4 + test/hil/ci.json | 15 + test/hil/core_cdc/core_cdc.ino | 121 +++++ .../hil/core_cdc_tinyusb/core_cdc_tinyusb.ino | 1 + test/hil/core_timing/core_timing.ino | 100 ++++ test/hil/local.example.json | 19 + 22 files changed, 2187 insertions(+), 72 deletions(-) create mode 120000 .agents create mode 100644 .claude/skills/build/SKILL.md create mode 100644 .claude/skills/build/scripts/check_build.py create mode 100644 .claude/skills/hil-contract/SKILL.md create mode 100755 .claude/skills/hil-contract/scripts/agentrc_pin.py create mode 100755 .claude/skills/hil-contract/scripts/hil_remote.py create mode 100644 .claude/skills/hil-contract/scripts/hil_test.py create mode 100644 .claude/test/test_agentrc_pin.py create mode 100644 .claude/test/test_build.py create mode 100644 .claude/test/test_hil.py create mode 100644 .claude/test/test_hil_remote.py create mode 100644 .pre-commit-config.yaml mode change 100644 => 120000 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 test/hil/agentrc.json create mode 100644 test/hil/ci.json create mode 100644 test/hil/core_cdc/core_cdc.ino create mode 120000 test/hil/core_cdc_tinyusb/core_cdc_tinyusb.ino create mode 100644 test/hil/core_timing/core_timing.ino create mode 100644 test/hil/local.example.json diff --git a/.agents b/.agents new file mode 120000 index 000000000..c8161850a --- /dev/null +++ b/.agents @@ -0,0 +1 @@ +.claude \ No newline at end of file diff --git a/.claude/skills/build/SKILL.md b/.claude/skills/build/SKILL.md new file mode 100644 index 000000000..f34fa3709 --- /dev/null +++ b/.claude/skills/build/SKILL.md @@ -0,0 +1,77 @@ +--- +name: build +description: The Adafruit SAMD core build contract. Use when a change must be verified by building, when an agent needs the boards and sketches a diff affects, when a HIL run or debug session needs firmware and its build record, or when a workflow asks for "the project's build". Resolves scope to boards, compiles the bundled libraries' sketches and the HIL sketches with arduino-cli against this checkout as the adafruit:samd platform, reports pass/fail with the first error. +--- + +# Build contract + +`scripts/check_build.py` is the entry point for verifying a change; agents and workflows call it rather than composing +`arduino-cli compile` lines. It is this core's profile for agentrc's `arduino` skill (`arduino_build.py`) in core +mode, loaded from the installed agentrc (`${AGENTRC_DIR:-$HOME/.claude}`), never fetched; CI fetches the commit +`test/hil/agentrc.json` pins. `.claude/skills/hil-contract/scripts/agentrc_pin.py check` names the one in use. That +skill's page ("A core") is the shared behaviour: the isolated arduino-cli environment that makes this checkout the +`adafruit:samd` platform whatever is installed or linked under `~/Arduino/hardware`, bundled libraries as the core's +own, submodule identity and the record. This page is what is this core's. + +```bash +B=.claude/skills/build/scripts/check_build.py +python3 $B --scope # boards and sketches a change affects +python3 $B --base origin/master # same, from the branch diff +python3 $B --base origin/master --worktree # same, plus uncommitted and untracked changes +python3 $B --select-only (--scope ... | --base REF [--worktree] | --diff-file FILE) [--hil-config ROSTER] +python3 $B --board metro_m4 [--board ...] [--example libraries/Wire/examples/master_reader ...] +python3 $B --board metro_m4_express --shared --record build/hil/metro_m4_express.build.json \ + --hil-config test/hil/ci.json +``` + +Submodules (`libraries/Adafruit_TinyUSB_Arduino`, `libraries/Adafruit_ZeroDMA`) must be initialised at their +gitlinks first: `git submodule update --init`. Dependencies are CI's install list (`EXTRA_DEPENDENCIES`), installed +beforehand in the user's Arduino libraries (exit 2 names the `arduino-cli lib install` line). + +The last stdout line is JSON: `pass`; `firstError`, and `error` on exit 2 naming the cause or remedy; `boards[]` (one +row per board and sketch: `status` `ok`, `failed` or `error`, `fqbn`, `log`, `artifacts`, `platform`, +`foreignHeaders`, `libraries`, `removed`); `tests[]`; `paths[]` (each changed path's `rule` and `reason`); +`uncovered`; `dependencies`; `nothingToBuild`; `warnings`; `input` (HEAD, dirty, submodules); `narrowed`; `record` +when one was written. Exit 0 pass, 1 a build, test or record check failed, 2 usage or a missing prerequisite, 3 an +uncovered path; 2 outranks 1, and 1 outranks 3. Each build's log is `/check_build.log`. + +`--select-only` prints the selection and stops before any compile. With `--hil-config ` it adds +`hil.{boards,full,uncovered}`: the roster boards the change puts under the HIL battery, and each family (`samd21`, +`samd51`) the change reaches with no roster board on this host. + +## Reporting + +An agent that reports `buildOk` and `notes` (agentrc's `code-writer`) maps the JSON this way: `buildOk` is `pass`. A +pass with no build or test run is "nothing to build": say so in `notes`. Exit 3 is `buildOk` false with each +`uncovered` reason quoted verbatim. Exit 1 or 2 quotes `firstError` (and `error`). Each `warnings` entry is quoted in +`notes`. + +## Judgment + +- **Selection is the script's**, first match per path; `paths[].reason` says why: + - a changed file of a bundled library's sketch builds that sketch wherever it is eligible; + - `cores/arduino/startup.c` builds the representatives and the crystalless CI boards (`CRYSTALLESS`); + `cores/arduino/USB/` the Arduino-stack representatives; any other `cores/` file every representative; + - `variants//` every `boards.txt` board on that variant, CI's or not; `boards.txt` and `platform.txt` CI's + whole matrix and the rig board; + - SPI, Wire, HID and the ZeroDMA gitlink (`SHARED_LIBRARIES`) every representative; the TinyUSB gitlink the + TinyUSB-stack boards; another bundled library its own sketches on the representatives; + - `tools/build_all.py`, `tools/makeboards.py` and the CI workflow run the contract tests, which hold the board + table to CI's matrix and `boards.txt` to `makeboards.py`'s output; + - `bootloaders/`, `drivers/`, `extras/`, `scripts/`, `programmers.txt` and `post_install.bat` are uncovered + (`UNCOVERED`): nothing here builds, flashes or uses them; anything unmatched is uncovered too. +- **Boards** (`BOARDS`): CI's matrix with `tools/build_all.py`'s FQBNs (`adafruit:samd:adafruit_`), its + skip markers and its own skips (TinyUSB's examples only on a TinyUSB-stack variant, USBHost's never on one); every + other `boards.txt` board, built only when its variant changes; and the rig boards: `metro_m4_express` (the farm) + and `metro_m0_express` (a dev PC's SAMD21), each on the Arduino USB stack. Representatives: `metro_m0` and + `metro_m0_tinyusb` (SAMD21), `metro_m4_express` and `metro_m4_tinyusb` (SAMD51). +- **Sketches** are every `.ino` dir under `libraries/`, as CI scans them (`SDU/extras/SDUBoot` included). The + contract tests pin the ones no CI variant builds (`unbuildable_examples()`). +- **A HIL record** (`--board B --shared --record FILE --hil-config ROSTER`, by convention + `build/hil/.build.json`) is the HIL contract's build receipt. It carries `core_cdc` (Arduino stack, the + restoration image), `core_cdc_tinyusb` (the same source under the TinyUSB stack, its own FQBN) and `core_timing`; + each sketch's VID:PID is the `-DUSB_VID`/`-DUSB_PID` `platform.txt` passes to every compile. +- **Private dirs for parallel agents.** Each build uses `build/agent-//`, removed when it + passes; `--shared` builds into `build/hil//` under `build/hil/.lock` and keeps it for flashing. +- **Printing a command is not verification.** A verdict is the JSON of a run that happened; an agent that cannot run + the script reports the build as not run. diff --git a/.claude/skills/build/scripts/check_build.py b/.claude/skills/build/scripts/check_build.py new file mode 100644 index 000000000..1abe3074b --- /dev/null +++ b/.claude/skills/build/scripts/check_build.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""Build this core's bundled-library and HIL sketches with arduino-cli: the project's build contract (SKILL.md). + + check_build.py (--scope PATH... | --base REF [--worktree] | --diff-file FILE) [--example E]... [-j N] + check_build.py --board B... [--example E]... [--shared] [--build-property K=V]... + check_build.py --board B --shared --record FILE --hil-config ROSTER [--experimental --build-property K=V...] + check_build.py --select-only (--scope ... | --base REF [--worktree] | --diff-file FILE) [--hil-config ROSTER] + +stdout ends with one JSON line. Exit 0 pass, 1 a build or test failed, 2 usage or a missing +prerequisite ("error" names the remedy), 3 an uncovered path. 2 outranks 1, 1 outranks 3. + +This file is the core's profile for agentrc's arduino_build.py in core mode (the `arduino` skill), loaded from the +agentrc test/hil/agentrc.json pins; the mechanics are there. +""" + +import json +import re +import shlex +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[4] +sys.path.insert(0, str(ROOT / '.claude' / 'skills' / 'hil-contract' / 'scripts')) +import agentrc_pin # noqa: E402 + +LIBRARY_NAME = 'Adafruit SAMD (32-bits ARM Cortex-M0+ and Cortex-M4) Boards' # platform.txt name= +PLATFORM = 'adafruit:samd' +SKETCH_ROOTS = ('libraries',) +FQBN_PREFIX = 'adafruit:samd:adafruit_' # tools/build_all.py's + +# CI's matrix (.github/workflows/githubci.yml): each entry is build_all.py's variant, the FQBN suffix and the name its +# ..test.skip/.test.only markers use (`filters`) +CI_VARIANTS = ('circuitplayground_m0', 'feather_m4_can', 'hallowing', 'hallowing_m4', 'metro_m0', 'metro_m4', + 'pybadge_m4', 'pygamer_m4', 'pyportal_m4', 'pyportal_m4_titano', 'metro_m0:usbstack=tinyusb', + 'metro_m4:speed=120,usbstack=tinyusb') +SAMD51_IDS = ('feather_m4_can', 'hallowing_m4', 'metro_m4', 'pybadge_m4', 'pygamer_m4', 'pyportal_m4', + 'pyportal_m4_titano') +BOARDS = { + v.split(':')[0] + ('_tinyusb' if 'usbstack=tinyusb' in v else ''): { + 'fqbn': FQBN_PREFIX + v, 'family': 'samd51' if v.split(':')[0] in SAMD51_IDS else 'samd21', 'filters': v} + for v in CI_VARIANTS +} +# the rig board, named as in agentrc's board registry: Metro M4 under the Arduino USB stack (the core's default); +# `span` is above the UF2 bootloader (variants/metro_m4/linker_scripts/gcc/flash_with_bootloader.ld) and below the +# SAMD51J19's 512 KiB +BOARDS['metro_m4_express'] = {'fqbn': FQBN_PREFIX + 'metro_m4:speed=120,usbstack=arduino', 'family': 'samd51', + 'filters': 'metro_m4', 'span': (0x4000, 0x80000)} +# a dev PC's SAMD21 board (test/hil/local.example.json), never on the farm: above its 8 KiB bootloader +# (variants/metro_m0/linker_scripts/gcc/flash_with_bootloader.ld), below the SAMD21G18's 256 KiB +BOARDS['metro_m0_express'] = {'fqbn': FQBN_PREFIX + 'metro_m0:usbstack=arduino', 'family': 'samd21', + 'filters': 'metro_m0', 'span': (0x2000, 0x40000)} +REPRESENTATIVES = {'samd21': ['metro_m0', 'metro_m0_tinyusb'], 'samd51': ['metro_m4_express', 'metro_m4_tinyusb']} +CRYSTALLESS = ('circuitplayground_m0', 'pyportal_m4') # CI boards whose boards.txt flags define CRYSTALLESS +TINYUSB_BOARDS = ('metro_m0_tinyusb', 'metro_m4_tinyusb') +ARDUINO_USB_BOARDS = ('metro_m0', 'metro_m4_express') +# the sketches a HIL record carries, the first also the restoration image: it writes no flash. core_cdc_tinyusb is +# core_cdc's source built with the TinyUSB stack (fqbn()) +HIL_SKETCHES = {'device': ('test/hil/core_cdc', 'test/hil/core_cdc_tinyusb', 'test/hil/core_timing')} +CONTRACT_TESTS = {'build': '.claude/test/test_build.py', 'hil': '.claude/test/test_hil.py', + 'hil_remote': '.claude/test/test_hil_remote.py', 'agentrc_pin': '.claude/test/test_agentrc_pin.py'} +SMOKE_BOARDS = ('metro_m4_express',) +NOTHING = ('.claude/', '.github/', '.agents', 'AGENTS.md', 'CLAUDE.md', 'README.md', 'CHANGELOG', 'LICENSE', + 'VARIANT_COMPLIANCE_CHANGELOG', 'keywords.txt', '.gitignore', '.gitmodules', '.pre-commit-config.yaml', + '.coderabbit.yaml') +# outside both contracts: no build here makes or flashes them, so a change to one is reported uncovered +UNCOVERED = {'bootloaders/': 'prebuilt bootloaders and their own sources: no build or HIL run here makes or flashes ' + 'them', + 'drivers/': 'Windows USB drivers', 'extras/': 'Board Manager packaging', 'post_install.bat': + 'Windows driver install', 'programmers.txt': 'upload programmers: no build or HIL run uses them', + 'scripts/': 'J-Link and OpenOCD upload/debug scripts: no build or HIL run uses them'} +# libraries every sketch may pull in (SPI's DMA through ZeroDMA, HID through the USB stack): a change builds all +SHARED_LIBRARIES = ('SPI', 'Wire', 'HID', 'Adafruit_ZeroDMA') +# CI's lib install list (.github/workflows/githubci.yml) +EXTRA_DEPENDENCIES = ('Adafruit NeoPixel', 'Adafruit seesaw Library', 'Adafruit SPIFlash', 'FlashStorage', + 'MIDI Library', 'SD', 'SdFat - Adafruit Fork') +CONTRACT_FILES = ('.claude/skills/build/scripts/check_build.py', '.claude/skills/hil-contract/scripts/hil_test.py', + '.claude/skills/hil-contract/scripts/hil_remote.py', + '.claude/skills/hil-contract/scripts/agentrc_pin.py', 'test/hil/agentrc.json') +AGENTRC = None # set by engine(): agentrc_pin.identity() + + +def boards_txt_variants(): + """{boards.txt id: (build.variant, build.mcu)}, or {} where boards.txt is absent (a tree staged on a rig).""" + path = ROOT / 'boards.txt' + if not path.is_file(): + return {} + out = {} + for m in re.finditer(r'^(\w+)\.build\.(variant|mcu)=(.*)$', path.read_text(), re.M): + out.setdefault(m.group(1), {})[m.group(2)] = m.group(3).strip() + return {i: (v['variant'], v.get('mcu', '')) for i, v in out.items() if 'variant' in v} + + +# every other boards.txt board builds only when its own variant changes; `filters` is build_all.py's variant name +for _id, (_variant, _mcu) in boards_txt_variants().items(): + _name = _id.removeprefix('adafruit_') + if _name not in BOARDS: + BOARDS[_name] = {'fqbn': f'adafruit:samd:{_id}', 'filters': _name, + 'family': 'samd51' if _mcu == 'cortex-m4' else 'samd21'} + + +def variant_boards(variant): + """The board names whose boards.txt id builds `variant`: CI's entries for that id and the off-CI one.""" + ids = [i for i, (v, _) in boards_txt_variants().items() if v == variant] + return [b for b, e in BOARDS.items() if 'span' not in e and e['fqbn'].split(':')[2] in ids] + + +def library_sketches(lib): + return [e for e in E.examples() if e.startswith(f'libraries/{lib}/')] + + +def classify_source(path): + """The core's own source rules (arduino_build.classify falls through to the common ones on None; a changed file + of an example sketch is classified before this).""" + for prefix, why in UNCOVERED.items(): + if path == prefix or path.startswith(prefix): + return 'uncovered', None, why + if path == 'cores/arduino/startup.c': + return 'boards', tuple(b for f in REPRESENTATIVES.values() for b in f) + CRYSTALLESS, \ + 'clock and startup: both families, both USB stacks, crystal and crystalless' + if path.startswith('cores/arduino/USB/'): + return 'boards', ARDUINO_USB_BOARDS, 'the Arduino USB stack' + if path.startswith('cores/'): + return 'shared', None, 'core runtime' + if path.startswith('variants/'): + variant = path.split('/')[1] + boards = variant_boards(variant) + return ('boards', tuple(boards), f'variant {variant}') if boards else \ + ('uncovered', None, f'variant {variant} is built by no boards.txt board') + if path in ('boards.txt', 'platform.txt'): + return 'boards', tuple(b for b, e in BOARDS.items() if 'span' not in e and e['filters'] in CI_VARIANTS) + \ + ('metro_m4_express',), \ + 'board and build recipes: CI\'s matrix and the rig board' + if path in ('tools/build_all.py', 'tools/makeboards.py', '.github/workflows/githubci.yml'): + return 'tests', 'build', 'CI\'s matrix, filters or the boards.txt generator: the contract tests check parity' + if path == 'libraries/Adafruit_TinyUSB_Arduino': + return 'boards', TINYUSB_BOARDS, 'the bundled TinyUSB (gitlink): the TinyUSB-stack boards' + if path.startswith('libraries/'): + lib = path.split('/')[1] + if lib in SHARED_LIBRARIES: + return 'shared', None, f'bundled {lib}, which other libraries and sketches include' + sketches = library_sketches(lib) + return ('sketches', sketches, f'bundled {lib}') if sketches else \ + ('uncovered', None, f'bundled {lib} has no sketch to build') + return None + + +def exclude(board, example): + """tools/build_all.py's own skips: TinyUSB's examples only on a TinyUSB-stack variant, USBHost's never on one.""" + tinyusb = 'usbstack=tinyusb' in BOARDS[board].get('filters', board) + return (not tinyusb and example.startswith('libraries/Adafruit_TinyUSB_Arduino/')) or \ + (tinyusb and example.startswith('libraries/USBHost/')) + + +def fqbn(board, example): + """The board's FQBN; core_cdc_tinyusb takes the TinyUSB stack instead.""" + base = BOARDS[board]['fqbn'] + return base.replace('usbstack=arduino', 'usbstack=tinyusb') if example == 'test/hil/core_cdc_tinyusb' else base + + +def vid_pid(build_dir, board=None, roster_entry=None): + """VID:PID the sketch enumerates with: platform.txt's build.usb_flags pass -DUSB_VID/-DUSB_PID to every compile, + under either USB stack (compile_commands.json); None when absent or disagreeing.""" + try: + commands = json.loads((Path(build_dir) / 'compile_commands.json').read_text()) + except (OSError, ValueError): + return None + seen = set() + for entry in commands: + try: + argv = entry.get('arguments') or shlex.split(entry.get('command', '')) + except ValueError: + return None + flags = dict(a[2:].split('=', 1) for a in argv if a.startswith(('-DUSB_VID=', '-DUSB_PID='))) + if 'USB_VID' in flags and 'USB_PID' in flags: + seen.add(f'{int(flags["USB_VID"], 0):04x}:{int(flags["USB_PID"], 0):04x}') + return seen.pop() if len(seen) == 1 else None + + +E = None # agentrc's arduino_build, bound to this profile by engine() + + +def engine(): + """agentrc's arduino_build bound to this profile.""" + global AGENTRC, E + if E is None: + AGENTRC = agentrc_pin.identity() + E = agentrc_pin.load('skills/arduino/scripts/arduino_build.py').bind(sys.modules[__name__]) + return E + + +def main(argv=None): + try: + eng = engine() + except agentrc_pin.PinError as e: + print(json.dumps({'version': 2, 'pass': False, 'error': str(e), 'remedy': e.remedy})) + return 2 + return eng.main(argv) + + +if __name__ == '__main__': + sys.modules.setdefault('check_build', sys.modules[__name__]) + sys.exit(main()) diff --git a/.claude/skills/hil-contract/SKILL.md b/.claude/skills/hil-contract/SKILL.md new file mode 100644 index 000000000..188377105 --- /dev/null +++ b/.claude/skills/hil-contract/SKILL.md @@ -0,0 +1,124 @@ +--- +name: hil-contract +description: The Adafruit SAMD core HIL contract, under agentrc's `hil` rules. Use when running the core's HIL battery on rig boards (Metro M4 Express on the ci.lan farm, a dev PC's SAMD21 board), when a HIL run fails, hangs or reports a board locked, when reading its report, or for a manual session (flash, GDB, console, a check, a command) on a rig board from hw-validator or hw-debugger. +--- + +# HIL contract + +Read agentrc's `hil` skill first; this page supplies its contract sections. The mechanics are agentrc's `arduino` +skill (`arduino_hil.py`, `arduino_remote.py`), shared with Adafruit TinyUSB Arduino and Adafruit SPIFlash, whose HIL +contracts have the same commands, budgets, session rules and recovery; this page names what is this core's. Manual +runs only: no CI job runs this battery. Scripts, all in `scripts/`, each with its usage in its docstring: + +- `hil_test.py`: the harness on the host with the boards (this core's profile and tests). +- `hil_remote.py`: drives the harness on ci.lan from the dev PC that built the firmware. +- `agentrc_pin.py`: which agentrc both load their shared scripts from: the installed one + (`${AGENTRC_DIR:-$HOME/.claude}`) on a dev PC, never fetched; in CI the commit `test/hil/agentrc.json` pins; on the + rig, the copy `hil_remote.py` staged. It refuses an agentrc without core mode. + +```bash +H=.claude/skills/hil-contract/scripts +python3 $H/agentrc_pin.py check # the agentrc a run would load, validated; its root +``` + +## Hosts/rosters + +| Host | How to tell | Roster | +|----------|------------------------------------|------------------------------------------------------------| +| ci.lan | the HIL farm; reached over ssh | `test/hil/ci.json` (tracked) | +| a dev PC | a board on this PC's USB | `test/hil/local.json` (ignored; copy `local.example.json`) | + +ci.lan has no arduino-cli and no checkout of this core: firmware is built on the dev PC and staged by +`hil_remote.py`. On a dev PC without `test/hil/local.json`, ask the user for one; an agent that cannot ask returns +each requested board not-run naming the missing file. + +A roster entry is the `arduino` skill's (`name`, the board's name in agentrc's board registry and its lock; `uid`, the +DUT's USB serial; `usb_port`; `probe`, its debug probe's serial, whose registry entry supplies the flasher, or a dev +PC's unregistered board's own `flasher`; `debug`) plus `arduino.tests`: `["cdc_arduino", "cdc_tinyusb", "timing"]`. +A board's name must be a rig board of the build profile (`metro_m4_express`, `metro_m0_express`). + +ci.lan's `metro_m4_express` is also on tinyusb's roster, tinyuf2's, Adafruit TinyUSB Arduino's and Adafruit +SPIFlash's. This contract performs no rig-wide operation. **Farm check**: tinyusb's launcher, +`ssh ci.lan 'cd ~/code/tinyusb && python3 test/hil/helper/hil_farm_check.py -b ...'`. + +## Selection + +A fixed battery per board, the roster's `arduino.tests`, each over the DUT's USB CDC with a reply line carrying its +command's nonce (a missing, stale or other reply fails the test at its deadline): + +- `cdc_arduino` (`test/hil/core_cdc/`, the Arduino USB stack; also the restoration check): `INFO` must report + `stack=arduino` and the board's chip family; `ECHO` of 1, 63, 64, 65 and 1000 random bytes must return every byte + as sent, then a `PASS` line with their length and crc32. +- `cdc_tinyusb` (`test/hil/core_cdc_tinyusb/`, the same source built with `usbstack=tinyusb`): the same checks with + `stack=tinyusb`, through the core's TinyUSB integration and the bundled TinyUSB library. +- `timing` (`test/hil/core_timing/`, Arduino stack): `millis()` read twice 3 s apart must lie within the host's + monotonic bracket of the two exchanges widened by 1%, `micros()` must agree with `millis()` within 2 ms, and + `delay(1000)` measured by `micros()` must take 1000 to 1002 ms, widened by 1%. A coarse functional bound, no clock + accuracy claim; `INFO` reports `f_cpu` and `crystalless` for the record. + +`-b` narrows the boards. The PR-scoped selector is the build contract's: + +```bash +python3 .claude/skills/build/scripts/check_build.py --select-only --base origin/master --hil-config test/hil/ci.json +``` + +Its `hil.boards` are the roster boards to run, `hil.full` means the harness, a HIL sketch or a roster changed (every +board), and `hil.uncovered` names each family the change reaches with no board on that roster (on the farm, +`samd21`): report those uncovered, never pass. + +## Build + +Each run board needs a clean HIL record at HEAD (build skill, "A HIL record"), with the submodules at their gitlinks: + +```bash +python3 .claude/skills/build/scripts/check_build.py --board --shared --record build/hil/.build.json \ + --hil-config +``` + +The harness admits a record only as the `arduino` skill's "The HIL build record" entry says. A manual flash or GDB +session also takes an experimental record (`--experimental`). + +## Execution + +Budgets, the remote run and the manual session commands are Adafruit TinyUSB Arduino's HIL contract's, with this +core's names: the attempt dir is `~/hil-remote/adafruit-samd/`, `check --test` takes `cdc_arduino` (default), +`cdc_tinyusb` or `timing`, `session close` restores `core_cdc`. + +```bash +python3 $H/hil_remote.py run --run-id [-b BOARD]... +python3 $H/hil_remote.py wait --timeout 570 # repeat while it exits 3 +python3 $H/hil_test.py report .hil-remote//hil-run +python3 $H/hil_remote.py session open --run-id -b # then session exec/stage/close, as TinyUSB's +``` + +On the dev PC with the board: `python3 $H/hil_test.py run [-b BOARD]... test/hil/local.json`. + +## Reservation + +As the `arduino` skill's Locks: the board lock `/tmp/tinyusb-hil-locks/.lock` and the pending kind +`arduino-hil` are shared with tinyusb, tinyuf2, Adafruit TinyUSB Arduino and Adafruit SPIFlash. `hil_test.py run` and +`commission` lock each board themselves; a manual session holds it under a reason unique to its attempt, +`arduino-hil session `. Cleanup state is `core_cdc` flashed and checked, every child proven +ended, and the hold released. + +## Results + +`hil_test.py run` writes `hil_report.json` (schema `adafruit-samd-core-hil`) with the same rows, exits, verdicts and +delegated output (`hil_test.py report`) as Adafruit TinyUSB Arduino's contract; `firmware.fqbns` names each sketch's +FQBN, so a row says which USB stack each test ran. The caller retries a fail once under a new run id, per the `hil` +Caller rules. + +## Recovery + +Adafruit TinyUSB Arduino's Recovery, run in this core's attempt dir with its roster and records. The battery writes +no flash beyond the application span (above the UF2 bootloader, `BOARDS[...]['span']`) and keeps no state across +sketches, so restoring `core_cdc` is the whole cleanup. + +## Limits + +- **Coverage:** the SAMD51 (Metro M4 Express, crystal) under both USB stacks: USB CDC enumeration and bulk data + integrity, and the core's time base against the host. Not covered on hardware: every SAMD21 board (no farm board; + `metro_m0_express` runs from a dev PC's `local.json` only), crystalless startup, SERCOM UART/I2C/SPI, analog, + PWM, I2S, USBHost and every other peripheral (no fixture wired), the bootloaders, cold power-on, and every other + variant; those build only. +- **Commissioning:** pending the first farm run under these sketches. diff --git a/.claude/skills/hil-contract/scripts/agentrc_pin.py b/.claude/skills/hil-contract/scripts/agentrc_pin.py new file mode 100755 index 000000000..478c5fad1 --- /dev/null +++ b/.claude/skills/hil-contract/scripts/agentrc_pin.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""Where the build and HIL contracts load agentrc's shared scripts from (agentrc `arduino` and `hil` skills): + + staged a rig tree hil_remote.py staged (manifest.json and agentrc/, no .git): its copy, the manifest's identity + pin CI (GITHUB_ACTIONS or CI true): test/hil/agentrc.json's {url, commit}, fetched into + ${XDG_CACHE_HOME:-~/.cache}/adafruit-samd/agentrc-, outside this checkout + installed otherwise: ${AGENTRC_DIR:-$HOME/.claude}, the agentrc install every skill uses; never fetched + + agentrc_pin.py test run .claude/test against the selected agentrc (CI fetches the pin first); its exit + agentrc_pin.py fetch CI only: fetch the pin into the cache + agentrc_pin.py check validate the selected agentrc as a record would bind it, fetching nothing; print its root +""" + +import ast +import hashlib +import importlib.util +import json +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[4] +PIN_FILE = ROOT / 'test' / 'hil' / 'agentrc.json' +FETCH_HINT = 'python3 .claude/skills/hil-contract/scripts/agentrc_pin.py fetch' +INSTALL_HINT = ('install agentrc with its arduino skill (python3 /install.py install --skill), ' + 'or set AGENTRC_DIR to such a checkout') +MANIFEST = 'manifest.json' +PROFILE_API = 1 # arduino_build.py's PROFILE_API: the engines' profile interface these profiles implement +CORE_MODE = 'cli_env' # arduino_build.py's core mode (PLATFORM profiles), which API 1 does not promise +# the agentrc engines the contracts run; their FILES closures are what a staged rig tree carries and a record binds +ENGINES = ('skills/arduino/scripts/arduino_build.py', 'skills/arduino/scripts/arduino_hil.py', + 'skills/arduino/scripts/arduino_remote.py') + + +class PinError(Exception): + def __init__(self, msg, remedy=''): + super().__init__(msg) + self.remedy = remedy + + +def ci(): + """True in CI: GITHUB_ACTIONS or CI set true; an unset or empty one counts as false, any other value refused.""" + on = False + for var in ('GITHUB_ACTIONS', 'CI'): + value = os.environ.get(var, '').strip().lower() + if value in ('1', 'true', 'yes'): + on = True + elif value not in ('', '0', 'false', 'no'): + raise PinError(f'{var}={os.environ[var]!r} is neither true nor false') + return on + + +def pin(): + """test/hil/agentrc.json's (url, commit), refusing an unreadable pin or one naming no url and full commit hash.""" + try: + data = json.loads(PIN_FILE.read_text()) + except (OSError, ValueError, RecursionError) as e: + raise PinError(f'{PIN_FILE} is not readable JSON ({e})') from None + url, commit = (data.get('url'), data.get('commit')) if isinstance(data, dict) else (None, None) + if (not isinstance(url, str) or not url.strip() + or not isinstance(commit, str) or not re.fullmatch(r'[0-9a-f]{40}', commit)): + raise PinError(f'{PIN_FILE} names no {{url, commit}} with a 40-hex commit ({data!r})') + return url, commit + + +def cache_dir(commit): + base = os.environ.get('XDG_CACHE_HOME') or os.path.expanduser('~/.cache') + return Path(base) / 'adafruit-samd' / f'agentrc-{commit}' + + +def _git(root, *args): + return subprocess.run(['git', '-C', str(root), *args], capture_output=True, text=True, + env={k: v for k, v in os.environ.items() if not k.startswith('GIT_')}) + + +def problem(root, commit): + """Why `root` is not a clean checkout of `commit`, '' when it is.""" + if not (Path(root) / '.git').exists(): + return f'{root} is not a git checkout' + head = _git(root, 'rev-parse', 'HEAD') + if head.returncode or head.stdout.strip() != commit: + return f'{root} is at {head.stdout.strip() or "?"}, not the pin {commit}' + status = _git(root, 'status', '--porcelain') + if status.returncode or status.stdout.strip(): + return f'{root} has local changes: {status.stdout.strip()[:200]}' + return '' + + +def fetch(): + if not ci(): + raise PinError('fetch runs in CI only: a dev PC loads the installed agentrc', INSTALL_HINT) + url, commit = pin() + dest = cache_dir(commit) + if dest.exists(): + why = problem(dest, commit) + if why: + raise PinError(f'{dest} exists but {why}; remove it and fetch again') + return dest + tmp = dest.with_name(dest.name + f'.tmp-{os.getpid()}') + shutil.rmtree(tmp, ignore_errors=True) + tmp.mkdir(parents=True) + for args in (('init', '-q'), ('remote', 'add', 'origin', url), ('fetch', '-q', '--depth', '1', 'origin', commit), + ('checkout', '-q', '--detach', 'FETCH_HEAD')): + p = _git(tmp, *args) + if p.returncode: + shutil.rmtree(tmp, ignore_errors=True) + raise PinError(f'git {" ".join(args)}: {p.stderr.strip()}') + try: + tmp.rename(dest) + except OSError as e: # a concurrent first fetch renamed its checkout in first: checked below like ours + shutil.rmtree(tmp, ignore_errors=True) + if not dest.exists(): + raise PinError(f'{tmp} -> {dest}: {e}') + why = problem(dest, commit) + if why: + raise PinError(why) + return dest + + +def checkout(): + """The pinned checkout, refusing a missing, moved or modified one.""" + _, commit = pin() + dest = cache_dir(commit) + why = problem(dest, commit) + if why: + raise PinError(f'agentrc pin: {why}', FETCH_HINT) + return dest + + +def literal(root, rel, name, default=None): + """The literal a script assigns to `name` at module level, read without importing it.""" + for node in ast.parse((Path(root) / rel).read_text()).body: + if isinstance(node, ast.Assign) and any(getattr(t, 'id', None) == name for t in node.targets): + return ast.literal_eval(node.value) + return default + + +def files_of(root, rel): + """A script's FILES literal (agentrc hil_load.py).""" + return tuple(literal(root, rel, 'FILES', ())) + + +def closure(root, scripts): + """Every agentrc-relative file `scripts` load or launch, themselves included.""" + out = set() + for rel in scripts: + out.add(rel) + if (Path(root) / rel).is_file(): + out.update(files_of(root, rel)) + missing = sorted(r for r in out if not (Path(root) / r).is_file()) + if missing: + raise PinError(f'agentrc at {root} lacks {missing}') + return sorted(out) + + +def loader(root): + """agentrc's hil_load, bootstrapped as its docstring prescribes.""" + path = Path(root) / 'skills' / 'hil' / 'scripts' / 'hil_load.py' + spec = importlib.util.spec_from_file_location('agentrc_hil_load', path) + if spec.name not in sys.modules: + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + sys.modules.setdefault(spec.name, mod) + return sys.modules[spec.name].load(spec.name, spec.origin) + + +def staged(): + """A rig tree hil_remote.py staged: no .git, and both its manifest and its agentrc copy; one without the other is + refused, never taken for a checkout.""" + if (ROOT / '.git').exists(): + return False + marks = {MANIFEST: (ROOT / MANIFEST).is_file(), 'agentrc/': (ROOT / 'agentrc').is_dir()} + if any(marks.values()) and not all(marks.values()): + raise PinError(f'{ROOT} is a partial staged tree: {[m for m, ok in marks.items() if not ok]} missing') + return all(marks.values()) + + +def source(): + return 'staged' if staged() else 'pin' if ci() else 'installed' + + +def installed(): + """${AGENTRC_DIR:-$HOME/.claude}, absolute, holding the arduino skill: never fetched, never another location.""" + r = Path(os.environ.get('AGENTRC_DIR') or Path.home() / '.claude').absolute() + if not (r / ENGINES[0]).is_file(): + raise PinError(f'{r} has no agentrc arduino skill ({ENGINES[0]})', INSTALL_HINT) + return r + + +def checkout_of(r, files): + """The one git checkout every closure file under r resolves into, at its own path there (a mix of installs, + say skills/hil from another checkout than skills/arduino, is refused).""" + top = _git((Path(r) / ENGINES[0]).resolve().parent, 'rev-parse', '--show-toplevel') + if top.returncode: + raise PinError(f'{(Path(r) / ENGINES[0]).resolve()} is in no git checkout: its commit is unknown', INSTALL_HINT) + co = Path(top.stdout.strip()).resolve() + stray = [f for f in files if (Path(r) / f).resolve() != (co / f).resolve()] + if stray: + raise PinError(f'{stray[:3]} under {r} resolve outside {co}: one agentrc checkout must hold them all', + INSTALL_HINT) + return co + + +def selected(): + """(source, root, closure) of the selected agentrc, refusing a missing closure file or engines implementing + another profile API than PROFILE_API before any is loaded.""" + src = source() + r = {'staged': lambda: ROOT / 'agentrc', 'pin': checkout, 'installed': installed}[src]() + files = closure(r, ENGINES) + api = literal(r, ENGINES[0], 'PROFILE_API') + if api != PROFILE_API: + raise PinError(f'agentrc at {r} implements profile API {api}, this core needs {PROFILE_API}: use an ' + f'agentrc that does, or move this core to the API it has', INSTALL_HINT) + if not any(isinstance(n, ast.FunctionDef) and n.name == CORE_MODE + for n in ast.parse((Path(r) / ENGINES[0]).read_text()).body): + raise PinError(f'agentrc at {r} has no core mode ({ENGINES[0]} defines no {CORE_MODE}): use an agentrc ' + 'that builds an Arduino core', INSTALL_HINT) + return src, r, files + + +def load(rel): + """agentrc's `rel`, loaded through its hil_load from the selected agentrc.""" + return loader(selected()[1]).load_file(rel) + + +def identity(): + """{commit, root, files, sha256, dirty, source}: where agentrc is loaded from, the engines' closure + (agentrc-relative) and each file's digest now, the commit it is from and whether the closure differs from it.""" + src, r, files = selected() + if src == 'staged': + try: + manifest = json.loads((ROOT / MANIFEST).read_text()) + except (OSError, ValueError, RecursionError) as e: + raise PinError(f'{ROOT / MANIFEST} is not readable JSON ({e}): stage it again with hil_remote.py') + staged_from = manifest.get('agentrc') if isinstance(manifest, dict) else None + if (not isinstance(staged_from, dict) or not isinstance(staged_from.get('commit'), str) + or not re.fullmatch(r'[0-9a-f]{40}', staged_from['commit']) + or not isinstance(staged_from.get('dirty'), bool)): + raise PinError(f'{ROOT / MANIFEST} names no agentrc commit and dirty flag ({staged_from!r}): stage it ' + 'again with hil_remote.py') + commit, dirty = staged_from['commit'], staged_from['dirty'] + elif src == 'pin': + commit, dirty = pin()[1], False # checkout() refused a moved or modified cache + else: + co = checkout_of(r, files) + head, status = _git(co, 'rev-parse', 'HEAD'), _git(co, 'status', '--porcelain', '--', *files) + if head.returncode or status.returncode: + raise PinError(f'git in {co}: {(head.stderr or status.stderr).strip()}') + commit, dirty = head.stdout.strip(), bool(status.stdout.strip()) + return {'commit': commit, 'root': r, 'files': files, 'dirty': dirty, 'source': src, + 'sha256': {f: hashlib.sha256((r / f).read_bytes()).hexdigest() for f in files}} + + +def test(): + """The contract tests against the selected agentrc, CI fetching the pin first; their exit.""" + if source() == 'pin': + fetch() + src, r, _ = selected() + # a git hook exports GIT_DIR and GIT_INDEX_FILE: a test's git in a scratch repository would act on this one + env = {k: v for k, v in os.environ.items() if not k.startswith('GIT_')} + if src == 'installed': + # the child runs in ROOT: a relative AGENTRC_DIR must still name the agentrc validated here + env['AGENTRC_DIR'] = str(r) + return subprocess.run([sys.executable, '-m', 'unittest', 'discover', '-s', '.claude/test', '-p', 'test_*.py'], + cwd=ROOT, env=env).returncode + + +def main(argv): + cmd = argv[0] if argv else '' + try: + if cmd == 'test': + return test() + if cmd == 'fetch': + print(fetch()) + elif cmd == 'check': + print(identity()['root']) + else: + print(__doc__, file=sys.stderr) + return 2 + except PinError as e: + print(f'error: {e}' + (f' ({e.remedy})' if e.remedy else ''), file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[1:])) diff --git a/.claude/skills/hil-contract/scripts/hil_remote.py b/.claude/skills/hil-contract/scripts/hil_remote.py new file mode 100755 index 000000000..a9d803706 --- /dev/null +++ b/.claude/skills/hil-contract/scripts/hil_remote.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Run the HIL harness on a remote rig (default ci.lan) from the dev PC that built the firmware. + +SKILL.md beside this directory is the contract. The rig gets binaries only: the harness, the roster, the build records +with their images and the agentrc files the harness loads, each pinned by digest in manifest.json, in a create-only +attempt dir / (agentrc hil_remote_core). Local records and collections live under .hil-remote/. + + hil_remote.py run --run-id ID [--host H] [--roster R] [-b BOARD]... [-t TEST]... + stage HEAD's clean build records and launch `hil_test.py run` detached; prints the launch JSON + hil_remote.py wait ID [--timeout S] until done (collected into .hil-remote/ID), or the timeout + hil_remote.py collect ID fetch whatever the attempt has now, whatever its state + hil_remote.py session open --run-id ID -b BOARD [--host H] [--roster R] + stage the board's clean record and take a hold on the board under a reason unique to the attempt + (`arduino-hil session DIR TOKEN`, kept in .hil-remote/ID.session.json) + hil_remote.py session stage ID RECORD... stage records (experimental too) into a new bundle; prints its options + hil_remote.py session exec ID flash|gdb|restore|console|check OPTS... hil_test.py's command under the hold + hil_remote.py session exec ID exec [--timeout S] -- ARGV... + hil_remote.py session close ID restore the board's restoration sketch, check it, release the hold (one + rig command); after an open whose hold was not taken, release one + the rig took anyway under the session's reason, restoring nothing + +Exits: run, session open/stage 0 launched/held/staged, 1 refused or failed (an `error:` line names why), 2 no usable +agentrc (agentrc_pin.py; the line names the remedy); wait 0 done, 3 running, 4 dead, 5 inconclusive (hil_remote_core); +collect 0 complete (a session's: fetched); session exec passes the rig command's exit through, 3 for a session whose +hold the rig no longer shows; session close passes it through too, except that 3 closes the session and +exits 0: `restoreExit: 3` in its JSON, the restore unconfirmed, or after an open whose hold was not taken +`releaseExit: 3`, nothing having run. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import agentrc_pin # noqa: E402 +import hil_test # noqa: E402 + + +def main(argv=None): + """hil_test's profile, run by agentrc's arduino_remote.py.""" + try: + hil_test.engine() + remote = agentrc_pin.load('skills/arduino/scripts/arduino_remote.py') + except agentrc_pin.PinError as e: + print(f'error: {e}' + (f' ({e.remedy})' if e.remedy else ''), file=sys.stderr) + return 2 + return remote.run(hil_test, argv) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/.claude/skills/hil-contract/scripts/hil_test.py b/.claude/skills/hil-contract/scripts/hil_test.py new file mode 100644 index 000000000..ef56114ff --- /dev/null +++ b/.claude/skills/hil-contract/scripts/hil_test.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""The Adafruit SAMD core HIL harness (SKILL.md beside this directory). Linux only. + + hil_test.py run [-b BOARD]... [-t TEST]... [--record-dir DIR] [--run-dir DIR] [--staged MANIFEST] ROSTER + hil_test.py commission -b BOARD [--record-dir DIR] ROSTER + hil_test.py flash -b BOARD --build-record R --sketch S --under-hold REASON [--staged M] ROSTER + hil_test.py restore -b BOARD --build-record R --under-hold REASON [--report PATH] [--release] [--staged M] ROSTER + hil_test.py gdb -b BOARD --build-record R --sketch S --under-hold REASON [--ex CMD]... [--timeout S] [--staged M] + ROSTER + hil_test.py exec -b BOARD --under-hold REASON [--timeout S] [--staged M] ROSTER -- ARGV... + hil_test.py console -b BOARD --under-hold REASON [--seconds S] [--baud N] [--reset] [--staged M] ROSTER + hil_test.py check -b BOARD --build-record R --under-hold REASON [--test T] [--staged M] ROSTER + hil_test.py report RUN_DIR [-b BOARD]... + hil_test.py lock (hold|release|status) ... agentrc's hil_lock.py + +flash, restore, gdb and check take --bundle DIR, a record bundle `hil_remote.py session stage` staged, +--build-record then inside it. restore --release passes only when the hold was released too. + +The last stdout line of every command but lock is JSON. Exits: 0 pass, 1 a board failed or did not run (cleaned up +or untouched), 2 refused before any hardware access, 3 a session command (--under-hold) whose hold the lock record +shows no live holder of, 4 a cleanup did not finish (board held). + +This file is the core's HIL profile for agentrc's arduino_hil.py (the `arduino` skill): its tests over the HIL +sketches' serial protocol (test/hil/core_cdc/core_cdc.ino, test/hil/core_timing/core_timing.ino); the mechanics are +there, loaded from the agentrc agentrc_pin.py selects. +""" + +import json +import os +import sys +import time +import zlib +from pathlib import Path + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[3] +sys.path.insert(0, str(HERE)) +sys.path.insert(0, str(ROOT / '.claude' / 'skills' / 'build' / 'scripts')) +import agentrc_pin # noqa: E402 +import check_build as BUILD # noqa: E402 + +TESTS = {'cdc_arduino': 'core_cdc', 'cdc_tinyusb': 'core_cdc_tinyusb', 'timing': 'core_timing'} +ROLE_TESTS = {'device': ('cdc_arduino', 'cdc_tinyusb', 'timing')} +RESTORE_TEST = {'device': 'cdc_arduino'} +REPORT_SCHEMA = 'adafruit-samd-core-hil' +# hil_remote.py (agentrc arduino_remote.py) +REMOTE_BASE = '~/hil-remote/adafruit-samd' +REMOTE_ROSTER = 'test/hil/ci.json' +HIL = '.claude/skills/hil-contract/scripts/hil_test.py' +# ECHO lengths: one byte, either side of a 64-byte full-speed bulk packet, and several packets +ECHO_LENGTHS = (1, 63, 64, 65, 1000) +TIMING_SPAN = 3.0 # seconds between the two TIME reads +DELAY_MS = 1000 +# a coarse functional bound on the core's time base against the host's monotonic clock, beyond the exchange's own +# round trips: no clock-accuracy claim (that needs the datasheet's oscillator figures) +TIMING_TOLERANCE = 0.01 + +E = None # agentrc's arduino_hil, bound to this profile by engine() + + +class Console: + """The DUT's USB CDC tty: one command line out, its reply line (the one carrying the command's nonce) back.""" + + def __init__(self, port, deadline, log): + self.port, self.deadline, self.log = port, deadline, log + + def send(self, data): + import serial + self.port.write_timeout = max(0.1, self.deadline - time.monotonic()) + try: + self.port.write(data) + except serial.SerialTimeoutException: + raise TimeoutError(f'write of {data[:40]!r} timed out') + + def read_exact(self, n): + got = b'' + while len(got) < n and time.monotonic() < self.deadline: + got += self.port.read(n - len(got)) + return got + + def ask(self, line, nonce): + self.send(line.encode() + b'\n') + self.log.append(f'> {line}') + return self.reply(nonce) + + def reply(self, nonce): + buf = b'' + while time.monotonic() < self.deadline: + buf += self.port.read(self.port.in_waiting or 1) # read(n) blocks for n bytes or the timeout + while b'\n' in buf: + raw, buf = buf.split(b'\n', 1) + text = raw.decode(errors='replace').strip() + if text: + self.log.append(f'< {text}') + words = text.split() + if len(words) >= 2 and words[0] in ('HIL_INFO', 'HIL_RESULT') and words[1] == nonce: + return words + raise TimeoutError(f'no reply for nonce {nonce} within the test timeout') + + +def fields(words): + return dict(w.split('=', 1) for w in words if '=' in w) + + +def chip_of(board): + return BUILD.BOARDS[board['name']]['family'] if board.get('name') in BUILD.BOARDS else None + + +def open_console(dev, deadline): + import serial + tty = E.node(dev, '*:*/tty/tty*', deadline) + port = serial.Serial(tty, 115200, timeout=0.2) + port.reset_input_buffer() + return tty, port + + +def session(dev, deadline, run_dir, nonce, check): + """Open the DUT's CDC tty, run check(console, tty) -> (ok, detail), keep the exchange in the run dir.""" + tty, port = open_console(dev, deadline) + log = [] + try: + return check(Console(port, deadline, log), tty) + finally: + port.close() + (Path(run_dir) / f'core-{nonce}.log').write_text('\n'.join(log) + '\n') + + +def info_problem(console, nonce, stack, board): + """'' when INFO reports the expected USB stack and the board's chip family, else why not; the INFO fields.""" + got = fields(console.ask(f'INFO {nonce}', nonce)) + want = {'stack': stack, 'chip': chip_of(board)} + wrong = {k: got.get(k) for k, v in want.items() if v and got.get(k) != v} + return (f'INFO {wrong} != {want}' if wrong else ''), got + + +def echo(console, nonce): + """ECHO each of ECHO_LENGTHS: the bytes come back as sent, then a PASS line with their length and crc32.""" + done = [] + for i, n in enumerate(ECHO_LENGTHS): + sub = f'{nonce}e{i}' + data = os.urandom(n) + console.send(f'ECHO {sub} {n}\n'.encode() + data) + got = console.read_exact(n) + if got != data: + return False, f'ECHO {n}: {len(got)} bytes back, {"equal" if got == data[:len(got)] else "differing"}' + f = fields(console.reply(sub)) + if (f.get('len'), f.get('crc')) != (str(n), f'{zlib.crc32(data):08x}'): + return False, f'ECHO {n}: result {f} (want len={n} crc={zlib.crc32(data):08x})' + done.append(n) + return True, f'echoed {done} bytes' + + +def cdc_test(stack): + def test(dev, nonce, deadline, tracker, run_dir, board): + """core_cdc under `stack`: INFO names the stack and chip, ECHO returns every byte across packet bounds.""" + def check(console, tty): + why, got = info_problem(console, nonce + 'i', stack, board) + if why: + return False, f'{tty}: {why}' + ok, detail = echo(console, nonce) + return ok, f'{tty}: {stack} stack, f_cpu={got.get("f_cpu")}: {detail}' + return session(dev, deadline, run_dir, nonce, check) + return test + + +def timed(console, line, nonce): + """(fields, host send time, host receive time) of one exchange.""" + t0 = time.monotonic() + words = console.ask(line, nonce) + return fields(words), t0, time.monotonic() + + +def timing_problem(dev_ms, host_lo, host_hi): + """'' when the DUT's elapsed ms lies within the host's bracket [lo, hi] widened by TIMING_TOLERANCE, else why.""" + lo, hi = host_lo * (1 - TIMING_TOLERANCE), host_hi * (1 + TIMING_TOLERANCE) + return '' if lo <= dev_ms <= hi else f'{dev_ms:.1f} ms outside the host bracket [{lo:.1f}, {hi:.1f}] ms' + + +def test_timing(dev, nonce, deadline, tracker, run_dir, board): + """core_timing: millis() and micros() advance with the host's monotonic clock over TIMING_SPAN seconds, agree with + each other, and delay(DELAY_MS) measures DELAY_MS within the bound (TIMING_TOLERANCE, coarse).""" + def check(console, tty): + why, info = info_problem(console, nonce + 'i', 'arduino', board) + if why: + return False, f'{tty}: {why}' + a, a0, a1 = timed(console, f'TIME {nonce}a', nonce + 'a') + time.sleep(TIMING_SPAN) + b, b0, b1 = timed(console, f'TIME {nonce}b', nonce + 'b') + try: + ms = (int(b['ms']) - int(a['ms'])) & 0xffffffff + us = (int(b['us']) - int(a['us'])) & 0xffffffff + except (KeyError, ValueError): + return False, f'{tty}: TIME replies {a}, {b}' + # the DUT read its clock somewhere inside each exchange: the elapsed host time lies in [b0 - a1, b1 - a0] + why = timing_problem(ms, (b0 - a1) * 1000, (b1 - a0) * 1000) + if why: + return False, f'{tty}: millis() {why}' + if abs(us / 1000 - ms) > 2: + return False, f'{tty}: micros() advanced {us} us while millis() advanced {ms} ms' + d, _, _ = timed(console, f'DELAY {nonce}d {DELAY_MS}', nonce + 'd') + try: + delay_us = int(d['us']) + except (KeyError, ValueError): + return False, f'{tty}: DELAY reply {d}' + why = timing_problem(delay_us / 1000, DELAY_MS, DELAY_MS + 2) # delay() may run past its last tick + if why: + return False, f'{tty}: delay({DELAY_MS}) {why}' + return True, f'{tty}: f_cpu={info.get("f_cpu")} crystalless={info.get("crystalless")}: millis {ms} ms over ' \ + f'host [{(b0 - a1) * 1000:.1f}, {(b1 - a0) * 1000:.1f}] ms, micros {us} us, delay({DELAY_MS}) ' \ + f'{delay_us} us' + return session(dev, deadline, run_dir, nonce, check) + + +TEST_FUNCS = {'cdc_arduino': cdc_test('arduino'), 'cdc_tinyusb': cdc_test('tinyusb'), 'timing': test_timing} + + +def engine(): + """agentrc's arduino_hil bound to this profile (and its build profile).""" + global E + if E is None: + BUILD.engine() + E = agentrc_pin.load('skills/arduino/scripts/arduino_hil.py').bind(sys.modules[__name__]) + return E + + +def main(argv=None): + try: + eng = engine() + except agentrc_pin.PinError as e: + print(json.dumps({'pass': False, 'error': str(e), 'remedy': e.remedy}), flush=True) + return 2 + return eng.main(argv) + + +if __name__ == '__main__': + sys.modules.setdefault('hil_test', sys.modules[__name__]) + sys.exit(main()) diff --git a/.claude/test/test_agentrc_pin.py b/.claude/test/test_agentrc_pin.py new file mode 100644 index 000000000..9a10bb3f3 --- /dev/null +++ b/.claude/test/test_agentrc_pin.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +"""agentrc_pin.py: which agentrc the contracts load (a rig's staged copy, CI's pin, else the installed one), the +identity a record binds, and the CI-only fetch. Git runs against throwaway repositories; nothing is fetched from the +network and nothing reaches a rig.""" +import contextlib +import io +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / '.claude' / 'skills' / 'hil-contract' / 'scripts')) +import agentrc_pin # noqa: E402 +import hil_test # noqa: E402 + +hil_test.engine() # the selected agentrc: a missing one fails here, naming the remedy + +HIL_LOAD = 'skills/hil/scripts/hil_load.py' +CLEAN_ENV = {k: v for k, v in os.environ.items() + if not k.startswith('GIT_') and k not in ('AGENTRC_DIR', 'CI', 'GITHUB_ACTIONS')} + + +def git(root, *a): + return subprocess.run(['git', '-C', str(root), *a], check=True, capture_output=True, text=True).stdout.strip() + + +def commit_all(repo, msg): + git(repo, 'add', '-A') + git(repo, '-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', 'commit', '-qm', msg) + return git(repo, 'rev-parse', 'HEAD') + + +def fake_agentrc(repo, hil_load=''): + """A git checkout of stand-in engines whose closure is themselves and hil_load; its HEAD.""" + repo.mkdir(parents=True) + git(repo, 'init', '-q') + for e in agentrc_pin.ENGINES: + (repo / e).parent.mkdir(parents=True, exist_ok=True) + (repo / e).write_text(f'FILES = ({HIL_LOAD!r},)\nPROFILE_API = {agentrc_pin.PROFILE_API}\n\n\n' + f'def {agentrc_pin.CORE_MODE}():\n pass\n') + (repo / HIL_LOAD).parent.mkdir(parents=True, exist_ok=True) + (repo / HIL_LOAD).write_text(hil_load) + (repo / 'README.md').write_text('not in the closure\n') + return commit_all(repo, 'agentrc') + + +class Env(unittest.TestCase): + """No CI, AGENTRC_DIR or GIT_* variables, and a scratch directory.""" + + def setUp(self): + self.stack = contextlib.ExitStack() + self.addCleanup(self.stack.close) + self.tmp = Path(self.stack.enter_context(tempfile.TemporaryDirectory())).resolve() + self.stack.enter_context(mock.patch.dict(os.environ, CLEAN_ENV, clear=True)) + + def env(self, **values): + self.stack.enter_context(mock.patch.dict(os.environ, values)) + + +class Selection(Env): + def test_ci_reads_github_actions_and_ci_and_refuses_anything_else(self): + self.assertFalse(agentrc_pin.ci()) + for var in ('GITHUB_ACTIONS', 'CI'): + for value, on in (('true', True), ('1', True), ('YES', True), ('false', False), ('0', False), ('', False)): + with self.subTest(var=var, value=value), mock.patch.dict(os.environ, {var: value}): + self.assertEqual(agentrc_pin.ci(), on) + with mock.patch.dict(os.environ, {'CI': 'maybe'}), \ + self.assertRaisesRegex(agentrc_pin.PinError, "CI='maybe' is neither true nor false"): + agentrc_pin.ci() + + def test_a_tree_is_staged_only_with_its_manifest_and_agentrc_copy(self): + with mock.patch.object(agentrc_pin, 'ROOT', self.tmp): + self.assertFalse(agentrc_pin.staged()) # a plain copy of the checkout + (self.tmp / 'agentrc').mkdir() + with self.assertRaisesRegex(agentrc_pin.PinError, r"partial staged tree: \['manifest.json'\] missing"): + agentrc_pin.staged() + (self.tmp / 'manifest.json').write_text('{}') + self.assertTrue(agentrc_pin.staged()) + (self.tmp / '.git').mkdir() + self.assertFalse(agentrc_pin.staged()) # a checkout is never a rig tree + + def test_the_source_is_staged_then_ci_then_installed(self): + with mock.patch.object(agentrc_pin, 'ROOT', self.tmp): + self.assertEqual(agentrc_pin.source(), 'installed') + self.env(GITHUB_ACTIONS='true') + self.assertEqual(agentrc_pin.source(), 'pin') + (self.tmp / 'agentrc').mkdir() + (self.tmp / 'manifest.json').write_text('{}') + self.assertEqual(agentrc_pin.source(), 'staged') + + def test_installed_is_agentrc_dir_else_home_claude(self): + repo, home = self.tmp / 'agentrc', self.tmp / 'home' + fake_agentrc(repo) + (home / '.claude').mkdir(parents=True) + (home / '.claude' / 'skills').symlink_to(repo / 'skills') + with mock.patch.dict(os.environ, {'AGENTRC_DIR': str(repo), 'HOME': str(home)}): + self.assertEqual(agentrc_pin.installed(), repo) + for unset in ({'HOME': str(home)}, {'HOME': str(home), 'AGENTRC_DIR': ''}): # as ${AGENTRC_DIR:-$HOME/.claude} + with self.subTest(env=unset), mock.patch.dict(os.environ, unset): + self.assertEqual(agentrc_pin.installed(), home / '.claude') + + def test_an_install_without_the_arduino_skill_names_the_install(self): + self.env(AGENTRC_DIR=str(self.tmp)) + with self.assertRaisesRegex(agentrc_pin.PinError, 'has no agentrc arduino skill') as e: + agentrc_pin.installed() + self.assertEqual(e.exception.remedy, agentrc_pin.INSTALL_HINT) + + def test_fetch_runs_in_ci_only(self): + with mock.patch.object(agentrc_pin, 'pin', side_effect=AssertionError('read the pin')): + with self.assertRaisesRegex(agentrc_pin.PinError, 'fetch runs in CI only'): + agentrc_pin.fetch() + with contextlib.redirect_stderr(io.StringIO()) as err: + self.assertEqual(agentrc_pin.main(['fetch']), 1) + self.assertIn('fetch runs in CI only', err.getvalue()) + + def test_ci_ignores_an_installed_agentrc(self): + repo, pinned = self.tmp / 'agentrc', self.tmp / 'pinned' + fake_agentrc(repo) + fake_agentrc(pinned) + self.env(AGENTRC_DIR=str(repo), CI='true') + with mock.patch.object(agentrc_pin, 'ROOT', self.tmp / 'lib'), \ + mock.patch.object(agentrc_pin, 'checkout', return_value=pinned): + self.assertEqual(agentrc_pin.selected()[:2], ('pin', pinned)) + + +class Installed(Env): + """$HOME/.claude/skills/{arduino,hil} linked into a checkout, as agentrc's install.py links them.""" + + def setUp(self): + super().setUp() + self.repo = self.tmp / 'agentrc' + self.head = fake_agentrc(self.repo) + self.claude = self.tmp / 'home' / '.claude' + (self.claude / 'skills').mkdir(parents=True) + for skill in ('arduino', 'hil'): + (self.claude / 'skills' / skill).symlink_to(self.repo / 'skills' / skill) + self.env(HOME=str(self.tmp / 'home')) + self.stack.enter_context(mock.patch.object(agentrc_pin, 'ROOT', self.tmp / 'lib')) + + def test_identity_through_the_skill_links_is_the_checkouts_head(self): + ident = agentrc_pin.identity() + self.assertEqual((ident['commit'], ident['root'], ident['dirty'], ident['source']), + (self.head, self.claude, False, 'installed')) + self.assertEqual(ident['files'], sorted([*agentrc_pin.ENGINES, HIL_LOAD])) + self.assertEqual(set(ident['sha256']), set(ident['files'])) + + def test_dirty_is_the_closures_alone(self): + (self.repo / 'README.md').write_text('edited\n') + self.assertFalse(agentrc_pin.identity()['dirty']) + (self.repo / HIL_LOAD).write_text('# edited\n') + self.assertEqual(agentrc_pin.identity()['commit'], self.head) + self.assertTrue(agentrc_pin.identity()['dirty']) + + def test_skills_linked_from_two_checkouts_are_refused_by_identity_and_check(self): + other = self.tmp / 'other' + fake_agentrc(other) + (self.claude / 'skills' / 'hil').unlink() + (self.claude / 'skills' / 'hil').symlink_to(other / 'skills' / 'hil') + with self.assertRaisesRegex(agentrc_pin.PinError, 'one agentrc checkout must hold them all'): + agentrc_pin.identity() + with contextlib.redirect_stderr(io.StringIO()) as err: + self.assertEqual(agentrc_pin.main(['check']), 1) + self.assertIn('one agentrc checkout must hold them all', err.getvalue()) + + def test_a_copy_outside_git_is_refused(self): + copy = self.tmp / 'copy' + shutil.copytree(self.repo, copy, ignore=shutil.ignore_patterns('.git')) + self.env(AGENTRC_DIR=str(copy)) + with self.assertRaisesRegex(agentrc_pin.PinError, 'in no git checkout'): + agentrc_pin.identity() + + def test_engines_of_another_profile_api_are_refused_before_loading(self): + (self.repo / agentrc_pin.ENGINES[0]).write_text(f'FILES = ({HIL_LOAD!r},)\nPROFILE_API = 99\n') + with mock.patch.object(agentrc_pin, 'loader', side_effect=AssertionError('loaded')), \ + self.assertRaisesRegex(agentrc_pin.PinError, 'implements profile API'): + agentrc_pin.load(agentrc_pin.ENGINES[0]) + + def test_engines_without_core_mode_are_refused_before_loading(self): + (self.repo / agentrc_pin.ENGINES[0]).write_text(f'FILES = ({HIL_LOAD!r},)\nPROFILE_API = 1\n') + with mock.patch.object(agentrc_pin, 'loader', side_effect=AssertionError('loaded')), \ + self.assertRaisesRegex(agentrc_pin.PinError, 'has no core mode'): + agentrc_pin.load(agentrc_pin.ENGINES[0]) + + def test_check_and_test_never_fetch(self): + with mock.patch.object(agentrc_pin, 'fetch', side_effect=AssertionError('fetched')): + with contextlib.redirect_stdout(io.StringIO()) as out: + self.assertEqual(agentrc_pin.main(['check']), 0) + self.assertEqual(out.getvalue().strip(), str(self.claude)) + with mock.patch.object(agentrc_pin.subprocess, 'run', return_value=mock.Mock(returncode=5)) as run: + self.assertEqual(agentrc_pin.test(), 5) + self.assertIn('discover', run.call_args.args[0]) + + def test_test_hands_the_child_the_agentrc_a_relative_dir_named_here(self): + self.addCleanup(os.chdir, os.getcwd()) + os.chdir(self.tmp) + self.env(AGENTRC_DIR=str(self.claude.relative_to(self.tmp))) + self.assertEqual(agentrc_pin.identity()['root'], self.claude) + with mock.patch.object(agentrc_pin.subprocess, 'run', return_value=mock.Mock(returncode=0)) as run: + self.assertEqual(agentrc_pin.test(), 0) + self.assertEqual(run.call_args.kwargs['cwd'], agentrc_pin.ROOT) + self.assertEqual(run.call_args.kwargs['env']['AGENTRC_DIR'], str(self.claude)) + + def test_a_hook_run_tests_git_init_leaves_the_hooks_repository_alone(self): + primary, target = self.repo, self.tmp / 'target' + git(primary, 'worktree', 'add', '-q', '--detach', str(self.tmp / 'wt')) + probe = agentrc_pin.ROOT / '.claude' / 'test' / 'test_probe.py' + probe.parent.mkdir(parents=True) + probe.write_text(f'''if True: + import subprocess, unittest + + class Probe(unittest.TestCase): + def test_init(self): + subprocess.run(['git', 'init', '-q', {str(target)!r}], check=True) + ''') + config = (primary / '.git' / 'config').read_bytes() + hook_git_dir = primary / '.git' / 'worktrees' / 'wt' # what git exports to a hook committing in wt + self.env(GIT_DIR=str(hook_git_dir), GIT_INDEX_FILE=str(hook_git_dir / 'index')) + run, done = subprocess.run, [] + with mock.patch.object(agentrc_pin.subprocess, 'run', + lambda *a, **k: done.append(run(*a, capture_output=True, text=True, **k)) or done[-1]): + self.assertEqual(agentrc_pin.test(), 0, done[-1].stdout + done[-1].stderr if done else None) + self.assertTrue((target / '.git').is_dir()) + self.assertEqual((primary / '.git' / 'config').read_bytes(), config) + + +class Runner(Env): + """test()'s child environment, whichever agentrc is selected.""" + + def test_the_child_gets_no_git_variable_from_any_source(self): + self.env(GIT_DIR='x', GIT_INDEX_FILE='x', GIT_CONFIG_COUNT='1') + before = dict(os.environ) + for src in ('installed', 'pin', 'staged'): + with self.subTest(src=src), mock.patch.object(agentrc_pin, 'selected', return_value=(src, self.tmp, [])), \ + mock.patch.object(agentrc_pin.subprocess, 'run', return_value=mock.Mock(returncode=0)) as run: + self.assertEqual(agentrc_pin.test(), 0) + env = run.call_args.kwargs['env'] + self.assertEqual([k for k in env if k.startswith('GIT_')], []) + self.assertEqual(env['PATH'], os.environ['PATH']) + self.assertEqual(env.get('AGENTRC_DIR'), str(self.tmp) if src == 'installed' else None) + self.assertEqual(dict(os.environ), before) + + +class Staged(Env): + def stage(self, manifest, hil_load=''): + """A rig tree: stand-in engines copied under agentrc/, and `manifest`.""" + fake_agentrc(self.tmp / 'src', hil_load) + shutil.copytree(self.tmp / 'src' / 'skills', self.tmp / 'agentrc' / 'skills') + (self.tmp / 'manifest.json').write_text(json.dumps(manifest)) + self.stack.enter_context(mock.patch.object(agentrc_pin, 'ROOT', self.tmp)) + self.stack.enter_context(mock.patch.object(agentrc_pin, 'checkout', + side_effect=AssertionError('a rig has no cache'))) + self.stack.enter_context(mock.patch.object(agentrc_pin, 'installed', + side_effect=AssertionError('a rig has no install'))) + + def test_identity_is_the_manifests_even_with_an_install_and_ci(self): + self.stage({'agentrc': {'commit': 'c' * 40, 'dirty': True, 'source': 'installed'}}) + self.env(AGENTRC_DIR=str(self.tmp / 'src'), CI='true') + ident = agentrc_pin.identity() + self.assertEqual((ident['commit'], ident['dirty'], ident['source'], ident['root']), + ('c' * 40, True, 'staged', self.tmp / 'agentrc')) + + def test_a_manifest_without_a_valid_agentrc_identity_is_refused(self): + self.stage({}) + for bad in (None, {}, {'commit': 'not-a-commit', 'dirty': False}, {'commit': '', 'dirty': False}, + {'commit': 'c' * 40}, {'commit': 'c' * 40, 'dirty': 'false'}, {'commit': int('1' * 40), 'dirty': False}): + with self.subTest(agentrc=bad): + (self.tmp / 'manifest.json').write_text(json.dumps({'agentrc': bad})) + with self.assertRaisesRegex(agentrc_pin.PinError, 'names no agentrc commit and dirty flag'): + agentrc_pin.identity() + for bad in ('[]', '"agentrc"'): # JSON, but not an object + with self.subTest(manifest=bad): + (self.tmp / 'manifest.json').write_text(bad) + with self.assertRaisesRegex(agentrc_pin.PinError, 'names no agentrc commit and dirty flag'): + agentrc_pin.identity() + for bad in (b'{not json', b'\xff', b'{"agentrc": ' + b'[' * 200000): + with self.subTest(manifest=bad): + (self.tmp / 'manifest.json').write_bytes(bad) + with self.assertRaisesRegex(agentrc_pin.PinError, 'is not readable JSON.*stage it again'): + agentrc_pin.identity() + + def test_a_staged_tree_without_the_engines_is_refused(self): + self.stage({'agentrc': {'commit': 'c' * 40, 'dirty': False}}) + os.remove(self.tmp / 'agentrc' / HIL_LOAD) + with self.assertRaisesRegex(agentrc_pin.PinError, 'lacks'): + agentrc_pin.load(agentrc_pin.ENGINES[0]) + + def test_a_staged_tree_loads_its_own_copy(self): + self.stage({'agentrc': {'commit': 'c' * 40, 'dirty': False}}, hil_load=( + 'class _L:\n def load_file(self, rel):\n return "STAGED:" + rel\n\n\n' + 'def load(name, origin):\n return _L()\n')) + with mock.patch.dict(sys.modules): + sys.modules.pop('agentrc_hil_load', None) + self.assertEqual(agentrc_pin.load(agentrc_pin.ENGINES[0]), 'STAGED:' + agentrc_pin.ENGINES[0]) + + +class Selected(unittest.TestCase): + """The agentrc this checkout really selects: the installed one on a dev PC, the pin in CI.""" + + def test_hil_test_binds_the_selected_identity(self): + ident = agentrc_pin.identity() + self.assertLessEqual({*agentrc_pin.ENGINES, HIL_LOAD, 'skills/hil/scripts/hil_remote_core.py'}, + set(ident['files'])) + self.assertEqual(ident['source'], 'pin' if agentrc_pin.ci() else 'installed') + self.assertEqual(hil_test.BUILD.AGENTRC, ident) + + def test_the_real_engines_load_from_their_staged_closure_alone(self): + # a fresh interpreter, so the staged modules never replace the engines the other tests load + code = '''if True: + import json, shutil, sys, sysconfig + from pathlib import Path + from unittest import mock + sys.path.insert(0, sys.argv[1]) + import agentrc_pin + ident = agentrc_pin.identity() + src, d = ident['root'], Path(sys.argv[2]) + for f in ident['files']: + (d / 'agentrc' / f).parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src / f, d / 'agentrc' / f) + (d / 'manifest.json').write_text(json.dumps({'agentrc': {'commit': ident['commit'], 'dirty': ident['dirty']}})) + before = set(sys.modules) + with mock.patch.object(agentrc_pin, 'ROOT', d), \\ + mock.patch.object(agentrc_pin, 'checkout', side_effect=AssertionError('a rig has no cache')), \\ + mock.patch.object(agentrc_pin, 'installed', side_effect=AssertionError('a rig has no install')): + staged = agentrc_pin.identity() + engines = [agentrc_pin.load(e).__file__ for e in agentrc_pin.ENGINES] + lib = tuple(sysconfig.get_paths()[k] for k in ('stdlib', 'platstdlib', 'purelib', 'platlib')) + new = sorted(f for n in set(sys.modules) - before + if (f := getattr(sys.modules[n], '__file__', None)) and not f.startswith(lib)) + print(json.dumps({'engines': engines, 'modules': new, 'source': staged['source'], + 'same': [staged[k] == ident[k] for k in ('commit', 'files', 'sha256')]})) + ''' + with tempfile.TemporaryDirectory() as d: + p = subprocess.run([sys.executable, '-I', '-c', code, str(Path(agentrc_pin.__file__).parent), d], + capture_output=True, text=True) + self.assertEqual(p.returncode, 0, p.stderr) + got = json.loads(p.stdout) + staged = Path(d, 'agentrc').resolve() + self.assertEqual(got['engines'], [str(Path(d, 'agentrc', e)) for e in agentrc_pin.ENGINES]) + self.assertEqual((got['source'], got['same']), ('staged', [True, True, True])) + for f in got['modules']: + self.assertTrue(Path(f).resolve().is_relative_to(staged), f) + + def test_closure_reads_files_without_importing(self): + with tempfile.TemporaryDirectory() as d: + (Path(d) / 'a.py').write_text("import nothing_here\nFILES = ('b.py',)\n") + (Path(d) / 'b.py').write_text('') + self.assertEqual(agentrc_pin.closure(d, ['a.py']), ['a.py', 'b.py']) + (Path(d) / 'b.py').unlink() + with self.assertRaisesRegex(agentrc_pin.PinError, r"lacks \['b.py'\]"): + agentrc_pin.closure(d, ['a.py']) + + +class PinFile(Env): + def test_the_repository_pin_is_valid(self): + url, commit = agentrc_pin.pin() + self.assertTrue(url) + self.assertRegex(commit, r'^[0-9a-f]{40}$') + + def test_a_missing_or_malformed_pin_is_refused_by_pin_and_check(self): + good = {'url': 'https://example.invalid/agentrc.git', 'commit': '0' * 40} + cases = {'missing': None, 'not json': '{', 'a list': '[]', 'no commit': {'url': good['url']}, + 'no url': {'commit': good['commit']}, 'a blank url': {**good, 'url': ' '}, + 'a short commit': {**good, 'commit': 'abc123'}, + 'a non-hex commit': {**good, 'commit': 'g' * 40}, 'a numeric commit': {**good, 'commit': 1}} + self.env(CI='true') + for name, content in cases.items(): + pin_file = self.tmp / f'{name}.json' + if content is not None: + pin_file.write_text(content if isinstance(content, str) else json.dumps(content)) + with self.subTest(name), mock.patch.object(agentrc_pin, 'PIN_FILE', pin_file), \ + mock.patch.object(agentrc_pin, 'ROOT', self.tmp / 'lib'): + with self.assertRaisesRegex(agentrc_pin.PinError, re.escape(str(pin_file))): + agentrc_pin.pin() + with contextlib.redirect_stderr(io.StringIO()) as err, contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(agentrc_pin.main(['check']), 1) + self.assertIn(f'error: {pin_file}', err.getvalue()) + + +class PinFetch(Env): + """agentrc_pin.fetch in CI, against a local repository standing in for the pinned agentrc.""" + + def setUp(self): + super().setUp() + self.env(XDG_CACHE_HOME=str(self.tmp), CI='true') + repo = self.tmp / 'upstream' + repo.mkdir() + git(repo, 'init', '-q') + (repo / 'f').write_text('x') + self.commit = commit_all(repo, 'pin') + self.stack.enter_context(mock.patch.object(agentrc_pin, 'pin', lambda: (str(repo), self.commit))) + self.dest = agentrc_pin.cache_dir(self.commit) + + def race(self, winner): + """fetch() with `winner` put at the cache dir by a concurrent fetch while this one checks out.""" + real = agentrc_pin._git + + def git_then_win(root, *args): + p = real(root, *args) + if args[0] == 'checkout': + winner() + return p + with mock.patch.object(agentrc_pin, '_git', git_then_win): + return agentrc_pin.fetch() + + def test_a_concurrent_first_fetch_reuses_the_winner(self): + won = self.tmp / 'won' + shutil.copytree(agentrc_pin.fetch(), won, symlinks=True) + shutil.rmtree(self.dest) + self.assertEqual(self.race(lambda: shutil.copytree(won, self.dest, symlinks=True)), self.dest) + self.assertEqual(agentrc_pin.problem(self.dest, self.commit), '') + self.assertEqual([p.name for p in self.dest.parent.iterdir()], [self.dest.name]) + + def test_a_bad_winner_is_refused(self): + def bad(): + self.dest.mkdir(parents=True) + (self.dest / 'junk').write_text('x') + with self.assertRaisesRegex(agentrc_pin.PinError, 'not a git checkout'): + self.race(bad) + + def test_a_moved_or_modified_cache_is_refused_naming_the_fetch(self): + agentrc_pin.fetch() + self.assertEqual(agentrc_pin.checkout(), self.dest) + (self.dest / 'f').write_text('edited') + with self.assertRaisesRegex(agentrc_pin.PinError, 'local changes') as e: + agentrc_pin.checkout() + self.assertEqual(e.exception.remedy, agentrc_pin.FETCH_HINT) + self.assertIn('is not a git checkout', agentrc_pin.problem(self.dest.parent, self.commit)) + self.assertIn(f'not the pin {"0" * 40}', agentrc_pin.problem(self.dest, '0' * 40)) + + +if __name__ == '__main__': + unittest.main() diff --git a/.claude/test/test_build.py b/.claude/test/test_build.py new file mode 100644 index 000000000..e7fb6156f --- /dev/null +++ b/.claude/test/test_build.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""The build contract's profile: the board table against CI's matrix and boards.txt, sketch eligibility against +tools/build_all.py, source classification and selection through agentrc's arduino_build.py in core mode, and the +VID:PID adapter. arduino-cli is not run; a real build is evidence only from a run of the script itself.""" +import ast +import json +import re +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / '.claude' / 'skills' / 'build' / 'scripts')) +import check_build as cb # noqa: E402 + +E = cb.engine() + + +def ci_matrix(): + text = (ROOT / '.github/workflows/githubci.yml').read_text() + return re.findall(r"^\s+- '([^']+)'", text.split('board:')[1].split('runs-on')[0], re.M) + + +def build_all_literal(name): + for node in ast.parse((ROOT / 'tools/build_all.py').read_text()).body: + if isinstance(node, ast.Assign) and any(getattr(t, 'id', None) == name for t in node.targets): + return ast.literal_eval(node.value) + raise KeyError(name) + + +class Tables(unittest.TestCase): + def test_ci_matrix_is_the_table(self): + self.assertEqual(sorted(ci_matrix()), sorted(cb.CI_VARIANTS)) + self.assertEqual(build_all_literal('FQBN_PREFIX'), cb.FQBN_PREFIX) + for v in cb.CI_VARIANTS: + entries = [b for b in cb.BOARDS.values() if b['filters'] == v and 'span' not in b] + self.assertEqual([b['fqbn'] for b in entries], [cb.FQBN_PREFIX + v], v) + + def test_ci_dependencies_are_the_extras(self): + text = (ROOT / '.github/workflows/githubci.yml').read_text() + line = next(ln for ln in text.splitlines() if 'arduino-cli lib install' in ln) + self.assertEqual(sorted(re.findall(r'"([^"]+)"', line)), sorted(cb.EXTRA_DEPENDENCIES)) + + def test_library_name_is_the_platforms(self): + self.assertIn(f'name={cb.LIBRARY_NAME}\n', (ROOT / 'platform.txt').read_text()) + + def test_every_boards_txt_board_has_an_entry_of_its_family(self): + ids = cb.boards_txt_variants() + self.assertTrue(ids) + for i, (_, mcu) in ids.items(): + entries = [b for b in cb.BOARDS.values() if b['fqbn'].split(':')[2] == i] + self.assertTrue(entries, i) + for b in entries: + self.assertEqual(b['family'], 'samd51' if mcu == 'cortex-m4' else 'samd21', i) + + def test_boards_txt_is_makeboards_output(self): + out = subprocess.run([sys.executable, str(ROOT / 'tools/makeboards.py')], capture_output=True, text=True, + check=True).stdout + self.assertEqual(out, (ROOT / 'boards.txt').read_text(), 'edit tools/makeboards.py and regenerate') + + def test_crystalless_representatives_define_it(self): + text = (ROOT / 'boards.txt').read_text() + for b in cb.CRYSTALLESS: + board_id = cb.BOARDS[b]['fqbn'].split(':')[2] + self.assertRegex(text, rf'(?m)^{board_id}\.build\.extra_flags=.*-DCRYSTALLESS\b', b) + + def test_representatives_and_rig_boards(self): + for family, boards in cb.REPRESENTATIVES.items(): + for b in boards: + self.assertEqual(cb.BOARDS[b]['family'], family) + self.assertEqual({b for b, v in cb.BOARDS.items() if 'span' in v}, {'metro_m4_express', 'metro_m0_express'}) + for b in ('metro_m4_express', 'metro_m0_express'): + self.assertIn('usbstack=arduino', cb.BOARDS[b]['fqbn']) + self.assertIn(cb.BOARDS[b]['filters'], cb.CI_VARIANTS) + + def test_hil_sketches_exist_and_tinyusb_is_core_cdc_rebuilt(self): + for sketch in cb.HIL_SKETCHES['device']: + self.assertTrue((ROOT / sketch / f'{Path(sketch).name}.ino').is_file(), sketch) + self.assertEqual((ROOT / 'test/hil/core_cdc_tinyusb/core_cdc_tinyusb.ino').read_bytes(), + (ROOT / 'test/hil/core_cdc/core_cdc.ino').read_bytes()) + self.assertEqual(cb.fqbn('metro_m4_express', 'test/hil/core_cdc_tinyusb'), + 'adafruit:samd:adafruit_metro_m4:speed=120,usbstack=tinyusb') + self.assertEqual(cb.fqbn('metro_m4_express', 'test/hil/core_cdc'), cb.BOARDS['metro_m4_express']['fqbn']) + + def test_unbuildable_examples_are_pinned(self): + # the sketches tools/build_all.py skips on every CI variant (their .test.only/.test.skip markers): no build + # here compiles them either + self.assertEqual(E.unbuildable_examples(), [ + 'libraries/Adafruit_TinyUSB_Arduino/examples/Host/Simple/host_device_info', + 'libraries/Adafruit_TinyUSB_Arduino/examples/MIDI/midi_pizza_box_dj', + 'libraries/Adafruit_TinyUSB_Arduino/examples/MassStorage/msc_esp32_file_browser', + 'libraries/Adafruit_TinyUSB_Arduino/examples/MassStorage/msc_internal_flash_samd', + 'libraries/Adafruit_ZeroDMA/examples/zerodma_adc']) + + def test_contract_files_exist(self): + for f in cb.CONTRACT_FILES: + self.assertTrue((ROOT / f).is_file(), f) + + +class Eligibility(unittest.TestCase): + """build_all.py's rules: its markers and its TinyUSB/USBHost skips.""" + + def sketch(self, lib): + return next(e for e in E.examples() if e.startswith(f'libraries/{lib}/')) + + def test_tinyusb_examples_only_on_tinyusb_variants(self): + ex = self.sketch('Adafruit_TinyUSB_Arduino') + self.assertEqual([b for b in ('metro_m0', 'metro_m0_tinyusb', 'metro_m4', 'metro_m4_tinyusb') + if E.eligible(b, ex)], ['metro_m0_tinyusb', 'metro_m4_tinyusb']) + + def test_usbhost_examples_never_on_tinyusb_variants(self): + ex = self.sketch('USBHost') + self.assertTrue(E.eligible('metro_m0', ex)) + self.assertFalse(E.eligible('metro_m0_tinyusb', ex)) + + def test_sketches_outside_examples_are_scanned_like_ci(self): + self.assertIn('libraries/SDU/extras/SDUBoot', E.examples()) + + +class Selection(unittest.TestCase): + def test_rules(self): + reps = tuple(b for f in cb.REPRESENTATIVES.values() for b in f) + cases = {'cores/arduino/startup.c': ('boards', reps + cb.CRYSTALLESS), + 'cores/arduino/USB/CDC.cpp': ('boards', cb.ARDUINO_USB_BOARDS), + 'cores/arduino/SERCOM.cpp': ('shared', None), + 'libraries/SPI/SPI.cpp': ('shared', None), + 'libraries/Adafruit_ZeroDMA': ('shared', None), + 'libraries/Adafruit_TinyUSB_Arduino': ('boards', cb.TINYUSB_BOARDS), + 'libraries/Servo/src/Servo.h': ('sketches', [e for e in E.examples() + if e.startswith('libraries/Servo/')]), + 'libraries/Wire/examples/master_reader/master_reader.ino': + ('example', 'libraries/Wire/examples/master_reader'), + 'test/hil/core_cdc/core_cdc.ino': ('hil_sketch', 'test/hil/core_cdc'), + 'test/hil/ci.json': ('hil', None), + '.claude/skills/build/scripts/check_build.py': ('tests', 'build'), + 'tools/build_all.py': ('tests', 'build'), 'tools/makeboards.py': ('tests', 'build'), + '.github/workflows/githubci.yml': ('tests', 'build'), + 'README.md': ('nothing', None), 'CLAUDE.md': ('nothing', None), '.coderabbit.yaml': ('nothing', None), + 'bootloaders/metroM4/main.c': ('uncovered', None), 'extras/pack.release.bash': ('uncovered', None), + 'scripts/openocd/jlink_samd51.cfg': ('uncovered', None), 'programmers.txt': ('uncovered', None), + 'weird/file': ('uncovered', None)} + for path, want in cases.items(): + self.assertEqual(E.classify(path)[:2], want, path) + + def test_a_variant_builds_every_board_on_it(self): + effect, boards, _ = E.classify('variants/metro_m0/variant.cpp') + self.assertEqual((effect, sorted(boards)), ('boards', ['metro_m0', 'metro_m0_tinyusb'])) + effect, boards, _ = E.classify('variants/itsybitsy_m4/variant.h') # on no CI board + self.assertEqual((effect, boards), ('boards', ('itsybitsy_m4',))) + self.assertEqual(E.classify('variants/nowhere/variant.h')[0], 'uncovered') + + def test_boards_txt_builds_ci_and_the_rig_board(self): + effect, boards, _ = E.classify('boards.txt') + self.assertEqual(effect, 'boards') + self.assertEqual(sorted(boards), sorted([b for b, e in cb.BOARDS.items() if 'span' not in e + and e['filters'] in cb.CI_VARIANTS] + ['metro_m4_express'])) + + def test_core_change_runs_the_farm_board_and_names_samd21_uncovered(self): + sel = E.select(['cores/arduino/wiring.c'], roster=['metro_m4_express']) + self.assertEqual(sorted(sel['builds']), sorted(b for f in cb.REPRESENTATIVES.values() for b in f)) + self.assertEqual(sel['hil']['boards'], ['metro_m4_express']) + self.assertEqual([u['family'] for u in sel['hil']['uncovered']], ['samd21']) + + def test_hil_sketch_builds_only_on_rig_boards(self): + sel = E.select(['test/hil/core_timing/core_timing.ino'], roster=['metro_m4_express']) + self.assertEqual(sorted(sel['builds']), ['metro_m0_express', 'metro_m4_express']) + self.assertTrue(sel['hil']['full']) + + +class Identity(unittest.TestCase): + def commands(self, *flags): + d = Path(self.tmp.name) + (d / 'compile_commands.json').write_text(json.dumps( + [{'file': f'/x/{i}.cpp', 'arguments': ['arm-none-eabi-g++', *f.split()]} for i, f in enumerate(flags)])) + return cb.vid_pid(d) + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + + def test_vid_pid_from_the_compile_flags(self): + self.assertEqual(self.commands('-DUSB_VID=0x239A -DUSB_PID=0x8020 -DUSBCON', '-O2'), '239a:8020') + + def test_vid_pid_absent_or_disagreeing_is_none(self): + self.assertIsNone(self.commands('-O2')) + self.assertIsNone(self.commands('-DUSB_VID=0x239A -DUSB_PID=0x8020', '-DUSB_VID=0x239A -DUSB_PID=0x8021')) + self.assertIsNone(cb.vid_pid(Path(self.tmp.name) / 'missing')) + + +if __name__ == '__main__': + unittest.main() diff --git a/.claude/test/test_hil.py b/.claude/test/test_hil.py new file mode 100644 index 000000000..4ef018072 --- /dev/null +++ b/.claude/test/test_hil.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""The HIL profile: the host side of the HIL sketches' serial protocol against a simulated DUT, the rosters, and the +profile bound to agentrc's arduino_hil.py. The simulation proves the host's parsing and verdicts only; what the +firmware does is a farm run's evidence.""" +import json +import sys +import tempfile +import time +import unittest +import zlib +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / '.claude' / 'skills' / 'hil-contract' / 'scripts')) +import hil_test as ht # noqa: E402 + +E = ht.engine() +BOARD = {'name': 'metro_m4_express'} + + +class FakeDut: + """core_cdc's and core_timing's protocol (test/hil/core_cdc/core_cdc.ino, test/hil/core_timing/core_timing.ino) + over a byte stream, with a clock running `rate` times the host's.""" + + def __init__(self, stack='arduino', chip='samd51', rate=1.0, corrupt=False, delay_us=1000400): + self.stack, self.chip, self.rate, self.corrupt, self.delay_us = stack, chip, rate, corrupt, delay_us + self.out, self.pending = b'', b'' + self.echo = None # (nonce, bytes left, bytes so far) + self.start = time.monotonic() + self.closed = False + + def reset_input_buffer(self): + pass + + def close(self): + self.closed = True + + @property + def in_waiting(self): + return min(len(self.out), 17) # replies arrive in fragments + + def read(self, n): + chunk, self.out = self.out[:n], self.out[n:] + return chunk + + def write(self, data): + self.pending += data + while self.pending: + if self.echo: + nonce, left, seen = self.echo + take, self.pending = self.pending[:left], self.pending[left:] + seen += take + self.out += bytes([take[0] ^ 1]) + take[1:] if self.corrupt and take else take + left -= len(take) + self.echo = (nonce, left, seen) if left else None + if not left: + self.out += f'\r\nHIL_RESULT {nonce} echo PASS len={len(seen)} crc={zlib.crc32(seen):08x}\r\n'.encode() + continue + if b'\n' not in self.pending: + return + line, self.pending = self.pending.split(b'\n', 1) + self.command(line.decode()) + + def now_us(self): + return int((time.monotonic() - self.start) * 1e6 * self.rate) + + def command(self, line): + w = line.split() + cmd, nonce = w[0], w[1] + if cmd == 'INFO': + reply = f'HIL_INFO {nonce} stack={self.stack} chip={self.chip} f_cpu=120000000 crystalless=0' + elif cmd == 'ECHO': + self.echo = (nonce, int(w[2]), b'') + return + elif cmd == 'TIME': + us = self.now_us() + reply = f'HIL_RESULT {nonce} time PASS ms={us // 1000} us={us}' + elif cmd == 'DELAY': + reply = f'HIL_RESULT {nonce} delay PASS ms={w[2]} us={self.delay_us}' + else: + reply = f'HIL_RESULT {nonce} ? REJECT why=unknown' + self.out += (reply + '\r\n').encode() + + +class Protocol(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + span = mock.patch.object(ht, 'TIMING_SPAN', 0.2) + span.start() + self.addCleanup(span.stop) + + def run_test(self, fn, dut, timeout=5): + with mock.patch.object(ht, 'open_console', return_value=('/dev/ttyACM0', dut)): + result = fn({'dir': '/x'}, 'abcd1234', time.monotonic() + timeout, None, self.tmp.name, BOARD) + self.assertTrue(dut.closed) + self.assertTrue((Path(self.tmp.name) / 'core-abcd1234.log').is_file()) + return result + + def test_cdc_passes_on_its_stack(self): + for stack in ('arduino', 'tinyusb'): + with self.subTest(stack=stack): + ok, detail = self.run_test(ht.TEST_FUNCS[f'cdc_{stack}'], FakeDut(stack=stack)) + self.assertTrue(ok, detail) + self.assertIn(f'echoed {list(ht.ECHO_LENGTHS)} bytes', detail) + + def test_cdc_fails_on_the_other_stack_or_chip(self): + ok, detail = self.run_test(ht.TEST_FUNCS['cdc_tinyusb'], FakeDut(stack='arduino')) + self.assertFalse(ok) + self.assertIn("'stack': 'arduino'", detail) + ok, detail = self.run_test(ht.TEST_FUNCS['cdc_arduino'], FakeDut(chip='samd21')) + self.assertFalse(ok) + + def test_cdc_fails_on_corrupted_echo(self): + ok, detail = self.run_test(ht.TEST_FUNCS['cdc_arduino'], FakeDut(corrupt=True)) + self.assertFalse(ok) + self.assertIn('differing', detail) + + def test_timing_passes_at_the_hosts_rate(self): + ok, detail = self.run_test(ht.test_timing, FakeDut()) + self.assertTrue(ok, detail) + + def test_timing_fails_on_a_clock_off_by_more_than_the_bound(self): + ok, detail = self.run_test(ht.test_timing, FakeDut(rate=1.05)) + self.assertFalse(ok) + self.assertIn('millis()', detail) + + def test_timing_fails_on_a_short_delay(self): + ok, detail = self.run_test(ht.test_timing, FakeDut(delay_us=900000)) + self.assertFalse(ok) + self.assertIn('delay(1000)', detail) + + def test_a_reply_with_another_nonce_times_out(self): + dut = FakeDut() + dut.command = lambda line: setattr(dut, 'out', dut.out + b'HIL_INFO other stack=arduino\r\n') + with self.assertRaises(TimeoutError): + self.run_test(ht.TEST_FUNCS['cdc_arduino'], dut, timeout=0.5) + + def test_timing_bracket(self): + self.assertEqual(ht.timing_problem(1000, 995, 1005), '') + self.assertIn('outside', ht.timing_problem(1100, 995, 1005)) + + +class Rosters(unittest.TestCase): + def test_checked_in_roster_loads(self): + roster = E.load_roster(ROOT / 'test/hil/ci.json') + self.assertEqual(sorted(roster), ['metro_m4_express']) + self.assertEqual(roster['metro_m4_express']['arduino']['tests'], list(ht.ROLE_TESTS['device'])) + + def test_example_roster_names_a_profile_board(self): + boards = json.loads((ROOT / 'test/hil/local.example.json').read_text())['boards'] + for b in boards: + self.assertIn('span', ht.BUILD.BOARDS[b['name']]) + self.assertEqual(b['arduino']['tests'], list(ht.ROLE_TESTS['device'])) + + +class Profile(unittest.TestCase): + def test_tests_and_sketches_agree(self): + sketches = {Path(s).name for s in ht.BUILD.HIL_SKETCHES['device']} + self.assertEqual(set(ht.TESTS.values()), sketches) # the engine's bind() checks the restore and the roles + + def test_engine_is_bound_to_this_profile(self): + self.assertIs(E.P, ht) + self.assertIs(E.B.P, ht.BUILD) + + +if __name__ == '__main__': + unittest.main() diff --git a/.claude/test/test_hil_remote.py b/.claude/test/test_hil_remote.py new file mode 100644 index 000000000..a0e910fa3 --- /dev/null +++ b/.claude/test/test_hil_remote.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""hil_remote.py: the wrapper hands this library's HIL profile to agentrc's arduino_remote.py (the agentrc +agentrc_pin.py selects) and turns an unavailable agentrc into exit 2, naming why and the remedy. The staging and the +rig transport are agentrc's tests; a real run is a farm run's evidence.""" +import contextlib +import io +import sys +import unittest +from pathlib import Path +from unittest import mock + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / '.claude' / 'skills' / 'hil-contract' / 'scripts')) +import agentrc_pin # noqa: E402 +import hil_remote # noqa: E402 +import hil_test # noqa: E402 + + +class Wrapper(unittest.TestCase): + def test_delegates_with_this_profile(self): + remote = mock.Mock() + remote.run.return_value = 7 + with mock.patch.object(hil_test, 'engine'), mock.patch.object(agentrc_pin, 'load', return_value=remote): + self.assertEqual(hil_remote.main(['wait', 'x']), 7) + remote.run.assert_called_once_with(hil_test, ['wait', 'x']) + + def test_an_unavailable_agentrc_is_exit_2_naming_why_and_the_remedy(self): + err = io.StringIO() + with mock.patch.object(hil_test, 'engine', side_effect=agentrc_pin.PinError('agentrc gone', 'install it')), \ + contextlib.redirect_stderr(err): + self.assertEqual(hil_remote.main(['wait', 'x']), 2) + self.assertEqual(err.getvalue(), 'error: agentrc gone (install it)\n') + + def test_remote_names_are_this_librarys(self): + self.assertTrue((ROOT / hil_test.HIL).is_file()) + self.assertTrue((ROOT / hil_test.REMOTE_ROSTER).is_file()) + + +if __name__ == '__main__': + unittest.main() diff --git a/.github/workflows/githubci.yml b/.github/workflows/githubci.yml index 66322215c..3452538c0 100644 --- a/.github/workflows/githubci.yml +++ b/.github/workflows/githubci.yml @@ -3,6 +3,17 @@ name: Build on: [pull_request, push] jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + submodules: 'true' + + - name: Run pre-commit + uses: pre-commit/action@v3.0.1 + build: strategy: fail-fast: false diff --git a/.gitignore b/.gitignore index ef8f29944..47c415e4f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ bootloaders/*/build/ *~ /libraries/**/build/ + +__pycache__/ +/build/ +/test/hil/local.json +/.hil-remote/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..da3dd88b3 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +repos: +- repo: https://github.com/pre-commit/mirrors-clang-format + rev: v15.0.7 + hooks: + - id: clang-format + # only the HIL sketches: the core's existing code is not reformatted + files: ^test/hil/ + types_or: [c++, c, header] + +- repo: local + hooks: + - id: agent-contract-tests + name: agent contract tests + # the installed agentrc on a dev PC; in CI the pinned one, fetched first + entry: python3 .claude/skills/hil-contract/scripts/agentrc_pin.py test + language: python + additional_dependencies: [pyserial==3.5] + pass_filenames: false + files: ^(\.claude/|test/hil/|boards\.txt$|tools/(build_all|makeboards)\.py$|\.github/workflows/githubci\.yml$) diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 841f3338b..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,72 +0,0 @@ -# AGENTS.md - -This file provides guidance to AI coding agents when working with code in this repository. - -## Project Overview - -Adafruit's fork of the Arduino Core for SAMD21 (Cortex-M0+) and SAMD51 (Cortex-M4) microcontrollers. Provides the board support package (BSP) installed via Arduino Board Manager for Adafruit SAMD boards. - -## Build & Test Commands - -This is an Arduino BSP — there is no standalone build. Code is compiled via Arduino CLI. - -**Default/preferred boards**: Use **metro_m0** (SAMD21/M0) and **metro_m4** (SAMD51/M4) as the reference boards for development and testing. - -```bash -# Install Arduino CLI and set up BSP (CI does this automatically) -# Build all examples for a specific board: -python3 tools/build_all.py adafruit_metro_m0 # SAMD21/M0 -python3 tools/build_all.py adafruit_metro_m4 # SAMD51/M4 - -# FQBN format: adafruit:samd:adafruit_ - -# Build a single sketch: -arduino-cli compile --fqbn adafruit:samd:adafruit_metro_m0 path/to/sketch # SAMD21 -arduino-cli compile --fqbn adafruit:samd:adafruit_metro_m4 path/to/sketch # SAMD51 - -# Upload: -arduino-cli upload --fqbn adafruit:samd:adafruit_metro_m0 -p /dev/ttyACM0 path/to/sketch -arduino-cli upload --fqbn adafruit:samd:adafruit_metro_m4 -p /dev/ttyACM0 path/to/sketch -``` - -CI runs `tools/build_all.py` across a matrix of boards to compile all library examples. When testing changes, always verify against both metro_m0 (M0/SAMD21) and metro_m4 (M4/SAMD51) to cover both chip families. - -## Architecture - -### Core Build System Files -- **`platform.txt`** — Defines compiler toolchain (arm-none-eabi-gcc), flags, upload tools (bossac, openocd), and build recipes. This is the central build configuration. -- **`boards.txt`** — Board definitions (36+ boards): MCU type, clock speed, memory layout, USB VID/PID, upload protocol, menu options (optimization level, USB stack selection). -- **`programmers.txt`** — J-Link and Atmel-ICE debugger configurations. - -### `cores/arduino/` — Core Runtime -The Arduino API implementation for SAMD. Key subsystems: -- **`startup.c`** — Clock initialization, oscillator config, peripheral clocks (differs significantly between SAMD21 and SAMD51 via `#ifdef __SAMD51__`) -- **`SERCOM.cpp/h`** — Abstraction over SAMD SERCOM peripherals. SERCOMs are flexible hardware blocks that can be configured as UART, SPI, or I2C. This is the foundation for Wire, SPI, and Serial libraries. -- **`wiring_analog.c`** — ADC/DAC operations with SAMD21/SAMD51 divergence -- **`cortex_handlers.c`** — ARM exception/interrupt vector table and handlers -- **`USB/`** — Native Arduino USB stack (CDC, HID, PluggableUSB) - -### `variants/` — Board Pin Definitions (49 variants) -Each variant has: -- **`variant.h`** — Pin count, pin-to-peripheral mappings, LED pins, SERCOM assignments -- **`variant.cpp`** — `g_APinDescription[]` array mapping Arduino pin numbers to physical port/pin/function/PWM/ADC -- **`linker_scripts/`** — Memory layout (`flash_with_bootloader.ld` is the typical one) - -When adding a new board: create a variant directory, add entries to `boards.txt`, and provide a bootloader binary. - -### `libraries/` — Bundled Libraries -- **SPI, Wire** — SERCOM-based, support multiple instances via different SERCOM peripherals -- **Adafruit_TinyUSB_Arduino** — Alternative USB stack (submodule), supports mass storage, HID, MIDI, etc. -- **Adafruit_ZeroDMA** — DMA controller library (submodule) -- **Servo, I2S, HID, SDU** — Standard peripheral libraries - -### `bootloaders/` — Prebuilt UF2 Bootloader Binaries -One binary per board. These enable USB drag-and-drop and bossac uploads. - -## Key Patterns - -- **SAMD21 vs SAMD51 divergence**: Most low-level code uses `#ifdef __SAMD51__` (or `_SAMD21_`) to handle register-level differences. The two chips have different clock trees, peripheral registers, and interrupt structures. -- **SERCOM multiplexing**: Pin functions are routed through SERCOM peripherals. A board's variant files define which SERCOMs are assigned to SPI, I2C, and UART. -- **Upload via bossac**: Default upload uses the SAM-BA bootloader protocol. Board enters bootloader mode via double-tap reset or 1200-baud touch. -- **USB stack choice**: Boards can select between Arduino's native USB stack and TinyUSB via the `build.usbstack` menu option in `boards.txt`. -- **Submodules**: `Adafruit_TinyUSB_Arduino` and `Adafruit_ZeroDMA` are git submodules — remember to `git submodule update --init` after cloning. diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 000000000..681311eb9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..eab4daaf1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,69 @@ +# Adafruit SAMD Core Agent Instructions + +## Working Rules + +- An Arduino core (the `adafruit:samd` board package) with no standalone build: sketches compile against this checkout + as that platform through the build contract's isolated arduino-cli environment, never the installed board package + or a `~/Arduino/hardware` link to another checkout. CI's board matrix is in `.github/workflows/githubci.yml`, its + FQBNs and skip rules in `tools/build_all.py`. Initialise the submodules (`git submodule update --init`) before any + build. +- Development boards: `metro_m0` (SAMD21) and `metro_m4` (SAMD51), each under both USB stacks; on hardware, the + ci.lan farm's `metro_m4_express`. +- Code references — runtime: `cores/arduino/` (`startup.c` clocks, `SERCOM.*`, `wiring_*`, `cortex_handlers.c`, the + Arduino USB stack in `USB/`); boards: `variants//` (`variant.*`, `linker_scripts/gcc/`), `boards.txt`, + `platform.txt`; bundled libraries: `libraries/` (SPI and Wire on SERCOM, `Adafruit_TinyUSB_Arduino` and + `Adafruit_ZeroDMA` as submodules); `bootloaders/`: prebuilt bootloaders and legacy bootloader sources with their + own make recipes, outside both contracts. + +## Code Rules + +- `boards.txt` is generated: edit `tools/makeboards.py` and regenerate (`python3 tools/makeboards.py > boards.txt`); + a contract test fails when they differ. +- SAMD21 and SAMD51 diverge under `__SAMD51__`; `CRYSTALLESS` and `USE_TINYUSB` come from `boards.txt` flags and the + `usbstack` menu, never from a source default. +- Check clock, register and peripheral behaviour against the SAMD21/SAMD51 datasheet and errata (agentrc's `read-doc`) + before changing or reviewing code that depends on it. +- Fix the submodules' code upstream (adafruit/Adafruit_TinyUSB_Arduino, adafruit/Adafruit_ZeroDMA) and bump the + gitlink; never edit it here. +- `.pre-commit-config.yaml` is authoritative: the contract tests, and clang-format v15 on `test/hil/` only (the core's + existing code is not reformatted). +- Keep each file's line endings. + +## Skills + +- Agents, skills and workflows beyond this repo's `.claude/` come from [agentrc](https://github.com/hathach/agentrc); + install it before agent work on this core. Its `arduino` skill's core mode holds the mechanics both contracts below + run, shared with Adafruit TinyUSB Arduino and Adafruit SPIFlash. +- Skill script tests live in `.claude/test/test_*.py`; pre-commit runs them against the installed agentrc + (`${AGENTRC_DIR:-$HOME/.claude}`); CI fetches the commit `test/hil/agentrc.json` pins instead. +- Hardware diagnosis and repair use agentrc's `hw-debugger`; independent hardware claims and committed-fix validation + use `hw-validator`; routine HIL runs use `hil-operator` — all orchestrated by `chief`. +- Core audit: the `code-audit` workflow with `dirs` (e.g. `cores/arduino`, `cores/arduino/USB`, `libraries/Wire`) and + `dimensions`: `clocks and startup: oscillator, DFLL and GCLK setup, crystal and crystalless boards, SAMD21 vs + SAMD51`; `SERCOM and its libraries: UART, SPI and I2C setup, pin muxing, the variants' assignments`; `USB stacks: + the Arduino stack and the TinyUSB integration, enumeration, CDC`; `interrupts and DMA: handler and priority setup, + shared state with ISRs, ZeroDMA`; `style: repo conventions and the clang-format scope`. + +## Build and Validate + +- Build contract: `.claude/skills/build/SKILL.md`. It resolves a change to boards and sketches and builds them with + arduino-cli against this checkout; `--shared --record` writes the HIL build record and keeps the images HIL flashes. +- HIL contract: `.claude/skills/hil-contract/SKILL.md`. It owns taking and releasing a rig board, names the roster for + the host you are on, runs the battery on a dev PC's board or the ci.lan farm, and gives hw-validator and + hw-debugger their manual sessions. Manual runs only: no CI job runs it. Hardware validation comes from its report, + never from a build; the farm has no SAMD21 board, so a change reaching SAMD21 is reported pending on hardware. +- Before submitting, and again after a rebase: `pre-commit run --all-files`. +- For code changes: `python3 .claude/skills/build/scripts/check_build.py --base origin/master --worktree`. + +## PRs and Follow-ups + +- Before opening or updating a PR, follow Build and Validate; CI does not run HIL. +- A non-draft PR opened on the human's request, unless they declined babysitting, or their request or yes to babysit + one, authorizes one headless `chief` to drive its reviews and CI to green: push commits to its branch, reply to and + resolve its review threads through `pr-babysit` with `autoPush: true` and `markSonar: true`. Launch it in the PR + worktree through agentrc's `headless-chief` skill (`chief_run.py`), never a bare `claude -p`, and relay its exit as + that skill directs. Merging needs the human's own words asking to merge that PR. `pr-babysit` args: + `{"pr": , "protected": "^test/hil/[^/]+\\.json$"}`; reviewers are agentrc's default (`protected` keeps the HIL + rosters and the agentrc pin out of automated fixes). +- This repository has no follow-up label: label a follow-up issue with an applicable existing label, or leave it + unlabeled. diff --git a/test/hil/agentrc.json b/test/hil/agentrc.json new file mode 100644 index 000000000..849c1e8b2 --- /dev/null +++ b/test/hil/agentrc.json @@ -0,0 +1,4 @@ +{ + "url": "https://github.com/hathach/agentrc.git", + "commit": "99ba80bc5984650cc848fcc16ebcea9cd963d7dd" +} diff --git a/test/hil/ci.json b/test/hil/ci.json new file mode 100644 index 000000000..e9e2a1324 --- /dev/null +++ b/test/hil/ci.json @@ -0,0 +1,15 @@ +{ + "boards": [ + { + "name": "metro_m4_express", + "uid": "9995AD485337433231202020FF100A34", + "probe": "000063728691", + "debug": { + "openocd": "-f interface/jlink.cfg -c \"transport select swd\" -c \"adapter speed 4000\" -f target/atsame5x.cfg" + }, + "arduino": { + "tests": ["cdc_arduino", "cdc_tinyusb", "timing"] + } + } + ] +} diff --git a/test/hil/core_cdc/core_cdc.ino b/test/hil/core_cdc/core_cdc.ino new file mode 100644 index 000000000..3cfc2860b --- /dev/null +++ b/test/hil/core_cdc/core_cdc.ino @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: MIT +// +// HIL sketch (.claude/skills/hil-contract/SKILL.md) and the restoration image: +// the board's resting firmware between runs, with no flash write path. +// test/hil/core_cdc_tinyusb builds this same file with usbstack=tinyusb. One +// command per line on Serial (the DUT's USB CDC), one reply line each carrying +// the command's nonce; anything else is answered REJECT why=. +// +// clang-format off +// INFO HIL_INFO stack= chip= +// f_cpu= crystalless=<0|1> +// ECHO then raw bytes, each echoed back as it arrives, then +// HIL_RESULT echo PASS len= crc= +// clang-format on + +#include + +#ifdef USE_TINYUSB +#include // Serial is the TinyUSB library's CDC +#define STACK "tinyusb" +#else +#define STACK "arduino" +#endif + +#ifdef __SAMD51__ +#define CHIP "samd51" +#else +#define CHIP "samd21" +#endif + +#ifdef CRYSTALLESS +#define IS_CRYSTALLESS 1 +#else +#define IS_CRYSTALLESS 0 +#endif + +#define ECHO_MAX 4096u + +static char nonce[24]; +static uint32_t echo_left, echo_len, echo_crc; + +static uint32_t crc32_update(uint32_t crc, uint8_t const *data, uint32_t len) { + crc = ~crc; + while (len--) { + crc ^= *data++; + for (int k = 0; k < 8; k++) { + crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u))); + } + } + return ~crc; +} + +static void handle(char *line) { + char *cmd = strtok(line, " "); + char *n = strtok(NULL, " "); + char *a = strtok(NULL, " "); + char *extra = strtok(NULL, " "); + if (!cmd || !n || strlen(n) >= sizeof(nonce) || extra) { + Serial.println("HIL_RESULT - ? REJECT why=malformed"); + return; + } + if (!strcmp(cmd, "INFO") && !a) { + Serial.printf("HIL_INFO %s stack=%s chip=%s f_cpu=%lu crystalless=%d\r\n", + n, STACK, CHIP, (unsigned long)F_CPU, IS_CRYSTALLESS); + return; + } + if (!strcmp(cmd, "ECHO") && a) { + char *end; + unsigned long len = strtoul(a, &end, 10); + if (*end || a[0] == '-' || len == 0 || len > ECHO_MAX) { + Serial.printf("HIL_RESULT %s echo REJECT why=length\r\n", n); + return; + } + strcpy(nonce, n); + echo_len = echo_left = len; + echo_crc = 0; + return; + } + Serial.printf("HIL_RESULT %s ? REJECT why=unknown\r\n", n); +} + +void setup() { Serial.begin(115200); } + +void loop() { + static char line[64]; + static size_t n; + // discarding an overlong line through its newline: its tail is no command + static bool overlong; + while (echo_left && Serial.available()) { + uint8_t buf[64]; + uint32_t got = 0; + while (got < sizeof(buf) && got < echo_left && Serial.available()) { + buf[got++] = (uint8_t)Serial.read(); + } + Serial.write(buf, got); + echo_crc = crc32_update(echo_crc, buf, got); + echo_left -= got; + if (!echo_left) { + Serial.printf("\r\nHIL_RESULT %s echo PASS len=%lu crc=%08lx\r\n", nonce, + (unsigned long)echo_len, (unsigned long)echo_crc); + } + } + while (!echo_left && Serial.available()) { + int c = Serial.read(); + if (c == '\r') { + continue; + } + if (c == '\n') { + line[n] = 0; + if (!overlong) { + handle(line); + } + n = 0; + overlong = false; + } else if (n < sizeof(line) - 1) { + line[n++] = (char)c; + } else { + overlong = true; + } + } +} diff --git a/test/hil/core_cdc_tinyusb/core_cdc_tinyusb.ino b/test/hil/core_cdc_tinyusb/core_cdc_tinyusb.ino new file mode 120000 index 000000000..a682ea24e --- /dev/null +++ b/test/hil/core_cdc_tinyusb/core_cdc_tinyusb.ino @@ -0,0 +1 @@ +../core_cdc/core_cdc.ino \ No newline at end of file diff --git a/test/hil/core_timing/core_timing.ino b/test/hil/core_timing/core_timing.ino new file mode 100644 index 000000000..f6195344d --- /dev/null +++ b/test/hil/core_timing/core_timing.ino @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT +// +// HIL sketch (.claude/skills/hil-contract/SKILL.md): the core's time base, read +// by the host against its own monotonic clock. One command per line on Serial +// (the DUT's USB CDC), one reply line each carrying the command's nonce; +// anything else is answered REJECT why=. +// +// clang-format off +// INFO HIL_INFO stack= chip= +// f_cpu= crystalless=<0|1> +// TIME HIL_RESULT time PASS ms= us= +// DELAY HIL_RESULT delay PASS ms= us= +// clang-format on + +#include + +#ifdef USE_TINYUSB +#define STACK "tinyusb" +#else +#define STACK "arduino" +#endif + +#ifdef __SAMD51__ +#define CHIP "samd51" +#else +#define CHIP "samd21" +#endif + +#ifdef CRYSTALLESS +#define IS_CRYSTALLESS 1 +#else +#define IS_CRYSTALLESS 0 +#endif + +#define DELAY_MAX 5000u + +static void handle(char *line) { + char *cmd = strtok(line, " "); + char *n = strtok(NULL, " "); + char *a = strtok(NULL, " "); + char *extra = strtok(NULL, " "); + if (!cmd || !n || extra) { + Serial.println("HIL_RESULT - ? REJECT why=malformed"); + return; + } + if (!strcmp(cmd, "INFO") && !a) { + Serial.printf("HIL_INFO %s stack=%s chip=%s f_cpu=%lu crystalless=%d\r\n", + n, STACK, CHIP, (unsigned long)F_CPU, IS_CRYSTALLESS); + return; + } + if (!strcmp(cmd, "TIME") && !a) { + uint32_t ms = millis(); + uint32_t us = micros(); + Serial.printf("HIL_RESULT %s time PASS ms=%lu us=%lu\r\n", n, + (unsigned long)ms, (unsigned long)us); + return; + } + if (!strcmp(cmd, "DELAY") && a) { + char *end; + unsigned long ms = strtoul(a, &end, 10); + if (*end || a[0] == '-' || ms == 0 || ms > DELAY_MAX) { + Serial.printf("HIL_RESULT %s delay REJECT why=length\r\n", n); + return; + } + uint32_t t0 = micros(); + delay(ms); + uint32_t t1 = micros(); + Serial.printf("HIL_RESULT %s delay PASS ms=%lu us=%lu\r\n", n, ms, + (unsigned long)(t1 - t0)); + return; + } + Serial.printf("HIL_RESULT %s ? REJECT why=unknown\r\n", n); +} + +void setup() { Serial.begin(115200); } + +void loop() { + static char line[64]; + static size_t n; + // discarding an overlong line through its newline: its tail is no command + static bool overlong; + while (Serial.available()) { + int c = Serial.read(); + if (c == '\r') { + continue; + } + if (c == '\n') { + line[n] = 0; + if (!overlong) { + handle(line); + } + n = 0; + overlong = false; + } else if (n < sizeof(line) - 1) { + line[n++] = (char)c; + } else { + overlong = true; + } + } +} diff --git a/test/hil/local.example.json b/test/hil/local.example.json new file mode 100644 index 000000000..100e8f2d6 --- /dev/null +++ b/test/hil/local.example.json @@ -0,0 +1,19 @@ +{ + "boards": [ + { + "name": "metro_m0_express", + "uid": "", + "flasher": { + "name": "jlink", + "uid": "", + "args": "-device ATSAMD21G18" + }, + "debug": { + "openocd": "-f interface/jlink.cfg -c \"transport select swd\" -c \"adapter speed 4000\" -f target/at91samdXX.cfg" + }, + "arduino": { + "tests": ["cdc_arduino", "cdc_tinyusb", "timing"] + } + } + ] +} From 4f52e158360531c4aa2f77220bed65ff09a4b7c3 Mon Sep 17 00:00:00 2001 From: hathach Date: Thu, 5 Nov 2026 18:14:46 +0700 Subject: [PATCH 2/9] Check each HIL reply's operation and status; keep core_cdc_tinyusb a copy --- .claude/skills/build/scripts/check_build.py | 2 +- .claude/skills/hil-contract/SKILL.md | 5 +- .../skills/hil-contract/scripts/hil_test.py | 32 +++-- .claude/test/test_build.py | 4 + .claude/test/test_hil.py | 31 ++++- test/hil/core_cdc/core_cdc.ino | 3 +- .../hil/core_cdc_tinyusb/core_cdc_tinyusb.ino | 123 +++++++++++++++++- 7 files changed, 183 insertions(+), 17 deletions(-) mode change 120000 => 100644 test/hil/core_cdc_tinyusb/core_cdc_tinyusb.ino diff --git a/.claude/skills/build/scripts/check_build.py b/.claude/skills/build/scripts/check_build.py index 1abe3074b..c598a5719 100644 --- a/.claude/skills/build/scripts/check_build.py +++ b/.claude/skills/build/scripts/check_build.py @@ -54,7 +54,7 @@ TINYUSB_BOARDS = ('metro_m0_tinyusb', 'metro_m4_tinyusb') ARDUINO_USB_BOARDS = ('metro_m0', 'metro_m4_express') # the sketches a HIL record carries, the first also the restoration image: it writes no flash. core_cdc_tinyusb is -# core_cdc's source built with the TinyUSB stack (fqbn()) +# a copy of core_cdc (a contract test keeps them equal) built with the TinyUSB stack (fqbn()) HIL_SKETCHES = {'device': ('test/hil/core_cdc', 'test/hil/core_cdc_tinyusb', 'test/hil/core_timing')} CONTRACT_TESTS = {'build': '.claude/test/test_build.py', 'hil': '.claude/test/test_hil.py', 'hil_remote': '.claude/test/test_hil_remote.py', 'agentrc_pin': '.claude/test/test_agentrc_pin.py'} diff --git a/.claude/skills/hil-contract/SKILL.md b/.claude/skills/hil-contract/SKILL.md index 188377105..06513bccf 100644 --- a/.claude/skills/hil-contract/SKILL.md +++ b/.claude/skills/hil-contract/SKILL.md @@ -49,8 +49,9 @@ command's nonce (a missing, stale or other reply fails the test at its deadline) - `cdc_arduino` (`test/hil/core_cdc/`, the Arduino USB stack; also the restoration check): `INFO` must report `stack=arduino` and the board's chip family; `ECHO` of 1, 63, 64, 65 and 1000 random bytes must return every byte as sent, then a `PASS` line with their length and crc32. -- `cdc_tinyusb` (`test/hil/core_cdc_tinyusb/`, the same source built with `usbstack=tinyusb`): the same checks with - `stack=tinyusb`, through the core's TinyUSB integration and the bundled TinyUSB library. +- `cdc_tinyusb` (`test/hil/core_cdc_tinyusb/`, a copy of `core_cdc` kept equal by a contract test, built with + `usbstack=tinyusb`): the same checks with `stack=tinyusb`, through the core's TinyUSB integration and the bundled + TinyUSB library. - `timing` (`test/hil/core_timing/`, Arduino stack): `millis()` read twice 3 s apart must lie within the host's monotonic bracket of the two exchanges widened by 1%, `micros()` must agree with `millis()` within 2 ms, and `delay(1000)` measured by `micros()` must take 1000 to 1002 ms, widened by 1%. A coarse functional bound, no clock diff --git a/.claude/skills/hil-contract/scripts/hil_test.py b/.claude/skills/hil-contract/scripts/hil_test.py index ef56114ff..ec17afcba 100644 --- a/.claude/skills/hil-contract/scripts/hil_test.py +++ b/.claude/skills/hil-contract/scripts/hil_test.py @@ -102,6 +102,17 @@ def fields(words): return dict(w.split('=', 1) for w in words if '=' in w) +class Rejected(Exception): + pass + + +def passed(words, op): + """The fields of a `HIL_RESULT PASS ...` reply; Rejected naming the reply for any other.""" + if words[0] != 'HIL_RESULT' or words[2:4] != [op, 'PASS']: + raise Rejected(f'{op}: {" ".join(words)}') + return fields(words) + + def chip_of(board): return BUILD.BOARDS[board['name']]['family'] if board.get('name') in BUILD.BOARDS else None @@ -120,6 +131,8 @@ def session(dev, deadline, run_dir, nonce, check): log = [] try: return check(Console(port, deadline, log), tty) + except Rejected as e: + return False, f'{tty}: {e}' finally: port.close() (Path(run_dir) / f'core-{nonce}.log').write_text('\n'.join(log) + '\n') @@ -127,7 +140,10 @@ def session(dev, deadline, run_dir, nonce, check): def info_problem(console, nonce, stack, board): """'' when INFO reports the expected USB stack and the board's chip family, else why not; the INFO fields.""" - got = fields(console.ask(f'INFO {nonce}', nonce)) + words = console.ask(f'INFO {nonce}', nonce) + if words[0] != 'HIL_INFO': + return f'INFO answered {" ".join(words)}', {} + got = fields(words) want = {'stack': stack, 'chip': chip_of(board)} wrong = {k: got.get(k) for k, v in want.items() if v and got.get(k) != v} return (f'INFO {wrong} != {want}' if wrong else ''), got @@ -143,7 +159,7 @@ def echo(console, nonce): got = console.read_exact(n) if got != data: return False, f'ECHO {n}: {len(got)} bytes back, {"equal" if got == data[:len(got)] else "differing"}' - f = fields(console.reply(sub)) + f = passed(console.reply(sub), 'echo') if (f.get('len'), f.get('crc')) != (str(n), f'{zlib.crc32(data):08x}'): return False, f'ECHO {n}: result {f} (want len={n} crc={zlib.crc32(data):08x})' done.append(n) @@ -163,11 +179,11 @@ def check(console, tty): return test -def timed(console, line, nonce): - """(fields, host send time, host receive time) of one exchange.""" +def timed(console, line, nonce, op): + """(fields of its PASS reply, host send time, host receive time) of one exchange.""" t0 = time.monotonic() words = console.ask(line, nonce) - return fields(words), t0, time.monotonic() + return passed(words, op), t0, time.monotonic() def timing_problem(dev_ms, host_lo, host_hi): @@ -183,9 +199,9 @@ def check(console, tty): why, info = info_problem(console, nonce + 'i', 'arduino', board) if why: return False, f'{tty}: {why}' - a, a0, a1 = timed(console, f'TIME {nonce}a', nonce + 'a') + a, a0, a1 = timed(console, f'TIME {nonce}a', nonce + 'a', 'time') time.sleep(TIMING_SPAN) - b, b0, b1 = timed(console, f'TIME {nonce}b', nonce + 'b') + b, b0, b1 = timed(console, f'TIME {nonce}b', nonce + 'b', 'time') try: ms = (int(b['ms']) - int(a['ms'])) & 0xffffffff us = (int(b['us']) - int(a['us'])) & 0xffffffff @@ -197,7 +213,7 @@ def check(console, tty): return False, f'{tty}: millis() {why}' if abs(us / 1000 - ms) > 2: return False, f'{tty}: micros() advanced {us} us while millis() advanced {ms} ms' - d, _, _ = timed(console, f'DELAY {nonce}d {DELAY_MS}', nonce + 'd') + d, _, _ = timed(console, f'DELAY {nonce}d {DELAY_MS}', nonce + 'd', 'delay') try: delay_us = int(d['us']) except (KeyError, ValueError): diff --git a/.claude/test/test_build.py b/.claude/test/test_build.py index e7fb6156f..59f9c1f64 100644 --- a/.claude/test/test_build.py +++ b/.claude/test/test_build.py @@ -163,6 +163,10 @@ def test_core_change_runs_the_farm_board_and_names_samd21_uncovered(self): self.assertEqual(sel['hil']['boards'], ['metro_m4_express']) self.assertEqual([u['family'] for u in sel['hil']['uncovered']], ['samd21']) + def test_the_cdc_source_and_its_tinyusb_copy_change_together(self): + sel = E.select(['test/hil/core_cdc/core_cdc.ino', 'test/hil/core_cdc_tinyusb/core_cdc_tinyusb.ino']) + self.assertEqual(sel['builds']['metro_m4_express'], ['test/hil/core_cdc', 'test/hil/core_cdc_tinyusb']) + def test_hil_sketch_builds_only_on_rig_boards(self): sel = E.select(['test/hil/core_timing/core_timing.ino'], roster=['metro_m4_express']) self.assertEqual(sorted(sel['builds']), ['metro_m0_express', 'metro_m4_express']) diff --git a/.claude/test/test_hil.py b/.claude/test/test_hil.py index 4ef018072..49a985092 100644 --- a/.claude/test/test_hil.py +++ b/.claude/test/test_hil.py @@ -23,8 +23,9 @@ class FakeDut: """core_cdc's and core_timing's protocol (test/hil/core_cdc/core_cdc.ino, test/hil/core_timing/core_timing.ino) over a byte stream, with a clock running `rate` times the host's.""" - def __init__(self, stack='arduino', chip='samd51', rate=1.0, corrupt=False, delay_us=1000400): + def __init__(self, stack='arduino', chip='samd51', rate=1.0, corrupt=False, delay_us=1000400, status='PASS'): self.stack, self.chip, self.rate, self.corrupt, self.delay_us = stack, chip, rate, corrupt, delay_us + self.status = status # what every HIL_RESULT reports self.out, self.pending = b'', b'' self.echo = None # (nonce, bytes left, bytes so far) self.start = time.monotonic() @@ -55,7 +56,8 @@ def write(self, data): left -= len(take) self.echo = (nonce, left, seen) if left else None if not left: - self.out += f'\r\nHIL_RESULT {nonce} echo PASS len={len(seen)} crc={zlib.crc32(seen):08x}\r\n'.encode() + self.out += (f'\r\nHIL_RESULT {nonce} echo {self.status} len={len(seen)} ' + f'crc={zlib.crc32(seen):08x}\r\n').encode() continue if b'\n' not in self.pending: return @@ -75,9 +77,9 @@ def command(self, line): return elif cmd == 'TIME': us = self.now_us() - reply = f'HIL_RESULT {nonce} time PASS ms={us // 1000} us={us}' + reply = f'HIL_RESULT {nonce} time {self.status} ms={us // 1000} us={us}' elif cmd == 'DELAY': - reply = f'HIL_RESULT {nonce} delay PASS ms={w[2]} us={self.delay_us}' + reply = f'HIL_RESULT {nonce} delay {self.status} ms={w[2]} us={self.delay_us}' else: reply = f'HIL_RESULT {nonce} ? REJECT why=unknown' self.out += (reply + '\r\n').encode() @@ -137,6 +139,27 @@ def test_a_reply_with_another_nonce_times_out(self): with self.assertRaises(TimeoutError): self.run_test(ht.TEST_FUNCS['cdc_arduino'], dut, timeout=0.5) + def test_a_reply_other_than_pass_fails(self): + for fn in (ht.TEST_FUNCS['cdc_arduino'], ht.test_timing): + with self.subTest(fn=fn): + ok, detail = self.run_test(fn, FakeDut(status='REJECT')) + self.assertFalse(ok) + self.assertIn('REJECT', detail) + + def test_info_answered_by_a_result_fails(self): + dut = FakeDut() + info = dut.command + + def command(line): + if line.startswith('INFO'): + dut.out += f'HIL_RESULT {line.split()[1]} ? REJECT why=unknown\r\n'.encode() + else: + info(line) + dut.command = command + ok, detail = self.run_test(ht.TEST_FUNCS['cdc_arduino'], dut) + self.assertFalse(ok) + self.assertIn('INFO answered', detail) + def test_timing_bracket(self): self.assertEqual(ht.timing_problem(1000, 995, 1005), '') self.assertIn('outside', ht.timing_problem(1100, 995, 1005)) diff --git a/test/hil/core_cdc/core_cdc.ino b/test/hil/core_cdc/core_cdc.ino index 3cfc2860b..f076c911c 100644 --- a/test/hil/core_cdc/core_cdc.ino +++ b/test/hil/core_cdc/core_cdc.ino @@ -2,7 +2,8 @@ // // HIL sketch (.claude/skills/hil-contract/SKILL.md) and the restoration image: // the board's resting firmware between runs, with no flash write path. -// test/hil/core_cdc_tinyusb builds this same file with usbstack=tinyusb. One +// test/hil/core_cdc_tinyusb holds a copy of this file, built with +// usbstack=tinyusb (a contract test keeps the two equal). One // command per line on Serial (the DUT's USB CDC), one reply line each carrying // the command's nonce; anything else is answered REJECT why=. // diff --git a/test/hil/core_cdc_tinyusb/core_cdc_tinyusb.ino b/test/hil/core_cdc_tinyusb/core_cdc_tinyusb.ino deleted file mode 120000 index a682ea24e..000000000 --- a/test/hil/core_cdc_tinyusb/core_cdc_tinyusb.ino +++ /dev/null @@ -1 +0,0 @@ -../core_cdc/core_cdc.ino \ No newline at end of file diff --git a/test/hil/core_cdc_tinyusb/core_cdc_tinyusb.ino b/test/hil/core_cdc_tinyusb/core_cdc_tinyusb.ino new file mode 100644 index 000000000..f076c911c --- /dev/null +++ b/test/hil/core_cdc_tinyusb/core_cdc_tinyusb.ino @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT +// +// HIL sketch (.claude/skills/hil-contract/SKILL.md) and the restoration image: +// the board's resting firmware between runs, with no flash write path. +// test/hil/core_cdc_tinyusb holds a copy of this file, built with +// usbstack=tinyusb (a contract test keeps the two equal). One +// command per line on Serial (the DUT's USB CDC), one reply line each carrying +// the command's nonce; anything else is answered REJECT why=. +// +// clang-format off +// INFO HIL_INFO stack= chip= +// f_cpu= crystalless=<0|1> +// ECHO then raw bytes, each echoed back as it arrives, then +// HIL_RESULT echo PASS len= crc= +// clang-format on + +#include + +#ifdef USE_TINYUSB +#include // Serial is the TinyUSB library's CDC +#define STACK "tinyusb" +#else +#define STACK "arduino" +#endif + +#ifdef __SAMD51__ +#define CHIP "samd51" +#else +#define CHIP "samd21" +#endif + +#ifdef CRYSTALLESS +#define IS_CRYSTALLESS 1 +#else +#define IS_CRYSTALLESS 0 +#endif + +#define ECHO_MAX 4096u + +static char nonce[24]; +static uint32_t echo_left, echo_len, echo_crc; + +static uint32_t crc32_update(uint32_t crc, uint8_t const *data, uint32_t len) { + crc = ~crc; + while (len--) { + crc ^= *data++; + for (int k = 0; k < 8; k++) { + crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u))); + } + } + return ~crc; +} + +static void handle(char *line) { + char *cmd = strtok(line, " "); + char *n = strtok(NULL, " "); + char *a = strtok(NULL, " "); + char *extra = strtok(NULL, " "); + if (!cmd || !n || strlen(n) >= sizeof(nonce) || extra) { + Serial.println("HIL_RESULT - ? REJECT why=malformed"); + return; + } + if (!strcmp(cmd, "INFO") && !a) { + Serial.printf("HIL_INFO %s stack=%s chip=%s f_cpu=%lu crystalless=%d\r\n", + n, STACK, CHIP, (unsigned long)F_CPU, IS_CRYSTALLESS); + return; + } + if (!strcmp(cmd, "ECHO") && a) { + char *end; + unsigned long len = strtoul(a, &end, 10); + if (*end || a[0] == '-' || len == 0 || len > ECHO_MAX) { + Serial.printf("HIL_RESULT %s echo REJECT why=length\r\n", n); + return; + } + strcpy(nonce, n); + echo_len = echo_left = len; + echo_crc = 0; + return; + } + Serial.printf("HIL_RESULT %s ? REJECT why=unknown\r\n", n); +} + +void setup() { Serial.begin(115200); } + +void loop() { + static char line[64]; + static size_t n; + // discarding an overlong line through its newline: its tail is no command + static bool overlong; + while (echo_left && Serial.available()) { + uint8_t buf[64]; + uint32_t got = 0; + while (got < sizeof(buf) && got < echo_left && Serial.available()) { + buf[got++] = (uint8_t)Serial.read(); + } + Serial.write(buf, got); + echo_crc = crc32_update(echo_crc, buf, got); + echo_left -= got; + if (!echo_left) { + Serial.printf("\r\nHIL_RESULT %s echo PASS len=%lu crc=%08lx\r\n", nonce, + (unsigned long)echo_len, (unsigned long)echo_crc); + } + } + while (!echo_left && Serial.available()) { + int c = Serial.read(); + if (c == '\r') { + continue; + } + if (c == '\n') { + line[n] = 0; + if (!overlong) { + handle(line); + } + n = 0; + overlong = false; + } else if (n < sizeof(line) - 1) { + line[n++] = (char)c; + } else { + overlong = true; + } + } +} From 2e44afd9d8cd25a54def1a73e91f3fa2d513acc6 Mon Sep 17 00:00:00 2001 From: hathach Date: Thu, 5 Nov 2026 18:25:26 +0700 Subject: [PATCH 3/9] Require agentrc's core feature by its declared literal; derive the CI board set --- .claude/skills/build/scripts/check_build.py | 7 ++-- .../hil-contract/scripts/agentrc_pin.py | 9 ++-- .../skills/hil-contract/scripts/hil_test.py | 2 +- .claude/test/test_agentrc_pin.py | 4 +- .claude/test/test_build.py | 13 +----- .claude/test/test_hil_remote.py | 41 +++++++++++++------ 6 files changed, 40 insertions(+), 36 deletions(-) diff --git a/.claude/skills/build/scripts/check_build.py b/.claude/skills/build/scripts/check_build.py index c598a5719..df175f852 100644 --- a/.claude/skills/build/scripts/check_build.py +++ b/.claude/skills/build/scripts/check_build.py @@ -40,6 +40,7 @@ 'fqbn': FQBN_PREFIX + v, 'family': 'samd51' if v.split(':')[0] in SAMD51_IDS else 'samd21', 'filters': v} for v in CI_VARIANTS } +CI_BOARDS = tuple(BOARDS) # the rig board, named as in agentrc's board registry: Metro M4 under the Arduino USB stack (the core's default); # `span` is above the UF2 bootloader (variants/metro_m4/linker_scripts/gcc/flash_with_bootloader.ld) and below the # SAMD51J19's 512 KiB @@ -112,7 +113,7 @@ def classify_source(path): """The core's own source rules (arduino_build.classify falls through to the common ones on None; a changed file of an example sketch is classified before this).""" for prefix, why in UNCOVERED.items(): - if path == prefix or path.startswith(prefix): + if path.startswith(prefix): return 'uncovered', None, why if path == 'cores/arduino/startup.c': return 'boards', tuple(b for f in REPRESENTATIVES.values() for b in f) + CRYSTALLESS, \ @@ -127,9 +128,7 @@ def classify_source(path): return ('boards', tuple(boards), f'variant {variant}') if boards else \ ('uncovered', None, f'variant {variant} is built by no boards.txt board') if path in ('boards.txt', 'platform.txt'): - return 'boards', tuple(b for b, e in BOARDS.items() if 'span' not in e and e['filters'] in CI_VARIANTS) + \ - ('metro_m4_express',), \ - 'board and build recipes: CI\'s matrix and the rig board' + return 'boards', CI_BOARDS + ('metro_m4_express',), 'board and build recipes: CI\'s matrix and the rig board' if path in ('tools/build_all.py', 'tools/makeboards.py', '.github/workflows/githubci.yml'): return 'tests', 'build', 'CI\'s matrix, filters or the boards.txt generator: the contract tests check parity' if path == 'libraries/Adafruit_TinyUSB_Arduino': diff --git a/.claude/skills/hil-contract/scripts/agentrc_pin.py b/.claude/skills/hil-contract/scripts/agentrc_pin.py index 478c5fad1..3239920c7 100755 --- a/.claude/skills/hil-contract/scripts/agentrc_pin.py +++ b/.claude/skills/hil-contract/scripts/agentrc_pin.py @@ -29,7 +29,7 @@ 'or set AGENTRC_DIR to such a checkout') MANIFEST = 'manifest.json' PROFILE_API = 1 # arduino_build.py's PROFILE_API: the engines' profile interface these profiles implement -CORE_MODE = 'cli_env' # arduino_build.py's core mode (PLATFORM profiles), which API 1 does not promise +FEATURE = 'core' # arduino_build.py's PROFILE_FEATURES entry for core mode (PLATFORM profiles), beyond API 1 # the agentrc engines the contracts run; their FILES closures are what a staged rig tree carries and a record binds ENGINES = ('skills/arduino/scripts/arduino_build.py', 'skills/arduino/scripts/arduino_hil.py', 'skills/arduino/scripts/arduino_remote.py') @@ -214,10 +214,9 @@ def selected(): if api != PROFILE_API: raise PinError(f'agentrc at {r} implements profile API {api}, this core needs {PROFILE_API}: use an ' f'agentrc that does, or move this core to the API it has', INSTALL_HINT) - if not any(isinstance(n, ast.FunctionDef) and n.name == CORE_MODE - for n in ast.parse((Path(r) / ENGINES[0]).read_text()).body): - raise PinError(f'agentrc at {r} has no core mode ({ENGINES[0]} defines no {CORE_MODE}): use an agentrc ' - 'that builds an Arduino core', INSTALL_HINT) + if FEATURE not in (literal(r, ENGINES[0], 'PROFILE_FEATURES') or ()): + raise PinError(f'agentrc at {r} has no core mode ({ENGINES[0]} PROFILE_FEATURES lacks {FEATURE!r}): use an ' + 'agentrc that builds an Arduino core', INSTALL_HINT) return src, r, files diff --git a/.claude/skills/hil-contract/scripts/hil_test.py b/.claude/skills/hil-contract/scripts/hil_test.py index ec17afcba..d90dfd182 100644 --- a/.claude/skills/hil-contract/scripts/hil_test.py +++ b/.claude/skills/hil-contract/scripts/hil_test.py @@ -40,7 +40,7 @@ import check_build as BUILD # noqa: E402 TESTS = {'cdc_arduino': 'core_cdc', 'cdc_tinyusb': 'core_cdc_tinyusb', 'timing': 'core_timing'} -ROLE_TESTS = {'device': ('cdc_arduino', 'cdc_tinyusb', 'timing')} +ROLE_TESTS = {'device': tuple(TESTS)} RESTORE_TEST = {'device': 'cdc_arduino'} REPORT_SCHEMA = 'adafruit-samd-core-hil' # hil_remote.py (agentrc arduino_remote.py) diff --git a/.claude/test/test_agentrc_pin.py b/.claude/test/test_agentrc_pin.py index 9a10bb3f3..41c3b3c63 100644 --- a/.claude/test/test_agentrc_pin.py +++ b/.claude/test/test_agentrc_pin.py @@ -43,8 +43,8 @@ def fake_agentrc(repo, hil_load=''): git(repo, 'init', '-q') for e in agentrc_pin.ENGINES: (repo / e).parent.mkdir(parents=True, exist_ok=True) - (repo / e).write_text(f'FILES = ({HIL_LOAD!r},)\nPROFILE_API = {agentrc_pin.PROFILE_API}\n\n\n' - f'def {agentrc_pin.CORE_MODE}():\n pass\n') + (repo / e).write_text(f'FILES = ({HIL_LOAD!r},)\nPROFILE_API = {agentrc_pin.PROFILE_API}\n' + f'PROFILE_FEATURES = ({agentrc_pin.FEATURE!r},)\n') (repo / HIL_LOAD).parent.mkdir(parents=True, exist_ok=True) (repo / HIL_LOAD).write_text(hil_load) (repo / 'README.md').write_text('not in the closure\n') diff --git a/.claude/test/test_build.py b/.claude/test/test_build.py index 59f9c1f64..35d6c34a8 100644 --- a/.claude/test/test_build.py +++ b/.claude/test/test_build.py @@ -2,7 +2,6 @@ """The build contract's profile: the board table against CI's matrix and boards.txt, sketch eligibility against tools/build_all.py, source classification and selection through agentrc's arduino_build.py in core mode, and the VID:PID adapter. arduino-cli is not run; a real build is evidence only from a run of the script itself.""" -import ast import json import re import subprocess @@ -23,17 +22,10 @@ def ci_matrix(): return re.findall(r"^\s+- '([^']+)'", text.split('board:')[1].split('runs-on')[0], re.M) -def build_all_literal(name): - for node in ast.parse((ROOT / 'tools/build_all.py').read_text()).body: - if isinstance(node, ast.Assign) and any(getattr(t, 'id', None) == name for t in node.targets): - return ast.literal_eval(node.value) - raise KeyError(name) - - class Tables(unittest.TestCase): def test_ci_matrix_is_the_table(self): self.assertEqual(sorted(ci_matrix()), sorted(cb.CI_VARIANTS)) - self.assertEqual(build_all_literal('FQBN_PREFIX'), cb.FQBN_PREFIX) + self.assertEqual(cb.agentrc_pin.literal(ROOT, 'tools/build_all.py', 'FQBN_PREFIX'), cb.FQBN_PREFIX) for v in cb.CI_VARIANTS: entries = [b for b in cb.BOARDS.values() if b['filters'] == v and 'span' not in b] self.assertEqual([b['fqbn'] for b in entries], [cb.FQBN_PREFIX + v], v) @@ -154,8 +146,7 @@ def test_a_variant_builds_every_board_on_it(self): def test_boards_txt_builds_ci_and_the_rig_board(self): effect, boards, _ = E.classify('boards.txt') self.assertEqual(effect, 'boards') - self.assertEqual(sorted(boards), sorted([b for b, e in cb.BOARDS.items() if 'span' not in e - and e['filters'] in cb.CI_VARIANTS] + ['metro_m4_express'])) + self.assertEqual(sorted(boards), sorted(cb.CI_BOARDS + ('metro_m4_express',))) def test_core_change_runs_the_farm_board_and_names_samd21_uncovered(self): sel = E.select(['cores/arduino/wiring.c'], roster=['metro_m4_express']) diff --git a/.claude/test/test_hil_remote.py b/.claude/test/test_hil_remote.py index a0e910fa3..0b6204289 100644 --- a/.claude/test/test_hil_remote.py +++ b/.claude/test/test_hil_remote.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -"""hil_remote.py: the wrapper hands this library's HIL profile to agentrc's arduino_remote.py (the agentrc -agentrc_pin.py selects) and turns an unavailable agentrc into exit 2, naming why and the remedy. The staging and the -rig transport are agentrc's tests; a real run is a farm run's evidence.""" +"""hil_remote.py, a wrapper running agentrc's arduino_remote.py (the agentrc agentrc_pin.py selects) with hil_test.py's +profile. The staging and session mechanics are agentrc's and tested there; nothing here reaches a rig. Stubs prove the +plumbing only; the transport is verified by a real run.""" import contextlib import io import sys @@ -15,23 +15,38 @@ import hil_remote # noqa: E402 import hil_test # noqa: E402 +hil_test.engine() # the selected agentrc: a missing one fails here, naming the remedy + class Wrapper(unittest.TestCase): - def test_delegates_with_this_profile(self): + def test_runs_the_remote_engine_with_the_hil_profile(self): remote = mock.Mock() - remote.run.return_value = 7 - with mock.patch.object(hil_test, 'engine'), mock.patch.object(agentrc_pin, 'load', return_value=remote): - self.assertEqual(hil_remote.main(['wait', 'x']), 7) - remote.run.assert_called_once_with(hil_test, ['wait', 'x']) + remote.run.return_value = 3 + with mock.patch.object(agentrc_pin, 'load', return_value=remote) as load: + self.assertEqual(hil_remote.main(['wait', 'r1']), 3) + load.assert_called_once_with('skills/arduino/scripts/arduino_remote.py') + remote.run.assert_called_once_with(hil_test, ['wait', 'r1']) - def test_an_unavailable_agentrc_is_exit_2_naming_why_and_the_remedy(self): - err = io.StringIO() + def test_an_unavailable_agentrc_exits_2_naming_why_and_the_remedy(self): with mock.patch.object(hil_test, 'engine', side_effect=agentrc_pin.PinError('agentrc gone', 'install it')), \ - contextlib.redirect_stderr(err): - self.assertEqual(hil_remote.main(['wait', 'x']), 2) + contextlib.redirect_stderr(io.StringIO()) as err: + self.assertEqual(hil_remote.main(['wait', 'r1']), 2) self.assertEqual(err.getvalue(), 'error: agentrc gone (install it)\n') - def test_remote_names_are_this_librarys(self): + def test_the_real_engine_parses_the_profiles_tests(self): + with contextlib.redirect_stderr(io.StringIO()) as err, self.assertRaises(SystemExit) as e: + hil_remote.main(['run', '--run-id', 'r1', '-t', 'nope']) + self.assertEqual(e.exception.code, 2) + # newer argparse quotes each choice + self.assertIn(', '.join(sorted(hil_test.TESTS)), err.getvalue().replace("'", '')) + + def test_the_rig_gets_every_contract_file(self): + for f in hil_test.BUILD.CONTRACT_FILES: + self.assertTrue((ROOT / f).is_file(), f) + self.assertIn('.claude/skills/hil-contract/scripts/hil_remote.py', hil_test.BUILD.CONTRACT_FILES) + self.assertIn('.claude/skills/hil-contract/scripts/agentrc_pin.py', hil_test.BUILD.CONTRACT_FILES) + + def test_remote_names_are_this_cores(self): self.assertTrue((ROOT / hil_test.HIL).is_file()) self.assertTrue((ROOT / hil_test.REMOTE_ROSTER).is_file()) From 8d26ac85264803f9328a2d45ac75b5e5f3b2ba8c Mon Sep 17 00:00:00 2001 From: hathach Date: Thu, 5 Nov 2026 18:30:07 +0700 Subject: [PATCH 4/9] Pin agentrc's core mode and record the first farm run --- .claude/skills/hil-contract/SKILL.md | 4 +++- test/hil/agentrc.json | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.claude/skills/hil-contract/SKILL.md b/.claude/skills/hil-contract/SKILL.md index 06513bccf..87ead0841 100644 --- a/.claude/skills/hil-contract/SKILL.md +++ b/.claude/skills/hil-contract/SKILL.md @@ -122,4 +122,6 @@ sketches, so restoring `core_cdc` is the whole cleanup. `metro_m0_express` runs from a dev PC's `local.json` only), crystalless startup, SERCOM UART/I2C/SPI, analog, PWM, I2S, USBHost and every other peripheral (no fixture wired), the bootloaders, cold power-on, and every other variant; those build only. -- **Commissioning:** pending the first farm run under these sketches. +- **Commissioning:** ci.lan's `metro_m4_express` (uid `9995AD485337433231202020FF100A34`, `239a:8020`) ran the + battery on 2026-11-05: `cdc_arduino`, `cdc_tinyusb` and `timing` passed and `core_cdc` was restored. `timing` read + `millis()` about 0.3% fast against the host, inside its bound; it makes no clock-accuracy claim. diff --git a/test/hil/agentrc.json b/test/hil/agentrc.json index 849c1e8b2..6b27be3dc 100644 --- a/test/hil/agentrc.json +++ b/test/hil/agentrc.json @@ -1,4 +1,4 @@ { "url": "https://github.com/hathach/agentrc.git", - "commit": "99ba80bc5984650cc848fcc16ebcea9cd963d7dd" + "commit": "420d333c0978e97afbea8a91ef3393fb2e076b23" } From f90cb4c92c790a9cf4930d1b2cf8828e6736245d Mon Sep 17 00:00:00 2001 From: hathach Date: Thu, 5 Nov 2026 18:51:42 +0700 Subject: [PATCH 5/9] Drop the checkout token before running pre-commit --- .github/workflows/githubci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/githubci.yml b/.github/workflows/githubci.yml index 3452538c0..665c91720 100644 --- a/.github/workflows/githubci.yml +++ b/.github/workflows/githubci.yml @@ -10,6 +10,7 @@ jobs: uses: actions/checkout@v4 with: submodules: 'true' + persist-credentials: false - name: Run pre-commit uses: pre-commit/action@v3.0.1 From 0cb3f9cf227c8270d224da61b1c66c7cf7cad2a8 Mon Sep 17 00:00:00 2001 From: hathach Date: Thu, 5 Nov 2026 18:51:42 +0700 Subject: [PATCH 6/9] Run the contract tests on platform.txt changes --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da3dd88b3..62af2ab68 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,4 +16,4 @@ repos: language: python additional_dependencies: [pyserial==3.5] pass_filenames: false - files: ^(\.claude/|test/hil/|boards\.txt$|tools/(build_all|makeboards)\.py$|\.github/workflows/githubci\.yml$) + files: ^(\.claude/|test/hil/|boards\.txt$|platform\.txt$|tools/(build_all|makeboards)\.py$|\.github/workflows/githubci\.yml$) From 4bfb9713ae45872219b6b6ca26724fa47b0dc0b2 Mon Sep 17 00:00:00 2001 From: hathach Date: Thu, 5 Nov 2026 19:14:04 +0700 Subject: [PATCH 7/9] Limit the CI workflow token to read-only contents --- .github/workflows/githubci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/githubci.yml b/.github/workflows/githubci.yml index 665c91720..e9995cead 100644 --- a/.github/workflows/githubci.yml +++ b/.github/workflows/githubci.yml @@ -2,6 +2,9 @@ name: Build on: [pull_request, push] +permissions: + contents: read + jobs: pre-commit: runs-on: ubuntu-latest From 4ebd708473c3bb701c0b1b2a9f5b9a1891352caf Mon Sep 17 00:00:00 2001 From: hathach Date: Thu, 5 Nov 2026 20:49:35 +0700 Subject: [PATCH 8/9] Pin agentrc at core mode's reviewed head --- test/hil/agentrc.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/hil/agentrc.json b/test/hil/agentrc.json index 6b27be3dc..a09b9cc2d 100644 --- a/test/hil/agentrc.json +++ b/test/hil/agentrc.json @@ -1,4 +1,4 @@ { "url": "https://github.com/hathach/agentrc.git", - "commit": "420d333c0978e97afbea8a91ef3393fb2e076b23" + "commit": "dd0c58f064a5d7a9ecbbe6da8bab1cdbd4439f92" } From d9a43444e99457ea2d4124ea57676c8389bc1636 Mon Sep 17 00:00:00 2001 From: hathach Date: Thu, 5 Nov 2026 22:57:40 +0700 Subject: [PATCH 9/9] Pin agentrc at core mode's merge commit --- test/hil/agentrc.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/hil/agentrc.json b/test/hil/agentrc.json index a09b9cc2d..a7aa3903b 100644 --- a/test/hil/agentrc.json +++ b/test/hil/agentrc.json @@ -1,4 +1,4 @@ { "url": "https://github.com/hathach/agentrc.git", - "commit": "dd0c58f064a5d7a9ecbbe6da8bab1cdbd4439f92" + "commit": "1f0decb5744bfa0c4afb15235ca3a06131dea71e" }