Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
20fa41f
family_json: split Windows compile commands with the C runtime's rules
HiFiPhile Oct 17, 2026
9ddd5a1
build: keep repo paths posix and tool output UTF-8 on Windows
HiFiPhile Oct 17, 2026
20a4d1b
build-doc: read and write the reference docs as UTF-8 and name paths …
HiFiPhile Oct 17, 2026
22bf5ff
make-release: read git and gh as UTF-8 and print PR titles as UTF-8
HiFiPhile Oct 17, 2026
1ea5f36
update-sponsor: run the codespell guard and print labels on Windows
HiFiPhile Oct 17, 2026
a544712
usbtest: refuse run_case and wedge_drill off Linux, print kernel sour…
HiFiPhile Oct 17, 2026
162915c
usb-kernel-recover: refuse off Linux before any verb runs
HiFiPhile Oct 17, 2026
f37e4e7
hil-contract: run hil_remote.py on Windows
HiFiPhile Oct 17, 2026
c9ae2d0
hil-contract: run test_hil_remote on Windows
HiFiPhile Oct 17, 2026
6a066b8
hil-contract: refuse unnameable paths before the lease, resolve rsync…
HiFiPhile Oct 17, 2026
3389a32
family_json: wait for the Windows lock past LK_LOCK's 10 s and docume…
HiFiPhile Oct 17, 2026
db1a6dd
build: split a quoted compiler path and report paths dropped as anoth…
HiFiPhile Oct 17, 2026
4188e28
usbtest: refuse off Linux through the verdict line after argparse, ke…
HiFiPhile Oct 17, 2026
6d3eeba
usbtest: fix the POSIX stdout test's escapes
HiFiPhile Oct 17, 2026
1b8832a
make-release: bump the release files as UTF-8 with LF, reconfigure st…
HiFiPhile Oct 17, 2026
20c298f
update-sponsor: reconfigure stdio only on Windows
HiFiPhile Oct 17, 2026
946230c
build-doc: sort boards by name on every host, test the exit status as…
HiFiPhile Oct 17, 2026
d9c1f71
update-sponsor: keep the codespell probe's typo out of the codespell …
HiFiPhile Oct 17, 2026
e6af283
build: count a dependency on another drive as outside in the coverage…
HiFiPhile Oct 17, 2026
ab1c368
skills: test the UTF-8 reads under a legacy locale, run them on updat…
hathach Oct 18, 2026
40f4a50
hil-contract: skip relative PATH dirs for rsync.exe, keep the exit wh…
hathach Oct 18, 2026
f9125b9
build: pin the child's output to UTF-8, keep the audited checkout's f…
hathach Oct 18, 2026
c385945
family_json: test the Windows lock and portable rule on every host, n…
hathach Oct 18, 2026
51dd1f1
hil-contract: never rewrite a killed run's record through a reused pi…
hathach Oct 18, 2026
4a3cc84
skills: tidy the Windows test fixtures and the usbtest refusal paragraph
hathach Oct 18, 2026
69132b4
Merge remote-tracking branch 'origin/master' into windows-skill-scripts
hathach Oct 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions .claude/skills/build-doc/scripts/gen_doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def metadata(path):
"""key: value pairs of the /* metadata: ... */ block, {} when the file or block is missing."""
if not path.exists():
return {}
m = re.search(r'/\*\s*metadata:(.*?)\*/', path.read_text(), re.DOTALL)
m = re.search(r'/\*\s*metadata:(.*?)\*/', path.read_text(encoding='utf-8'), re.DOTALL)
if not m:
return {}
return {k: v.strip() for k, v in re.findall(r'^[ \t]*(\w+):[ \t]*(.+)$', m.group(1), re.MULTILINE)}
Expand All @@ -30,7 +30,8 @@ def metadata(path):
def boards(bsp):
"""{manufacturer: {board: [name, family, url, note]}} plus the paths skipped for lack of metadata."""
vendors, skipped = {}, []
for family_dir in sorted(p for p in bsp.iterdir() if p.is_dir()):
# by name: Windows paths compare case-insensitively, which would reorder boards.rst
for family_dir in sorted((p for p in bsp.iterdir() if p.is_dir()), key=lambda p: p.name):
family_c = family_dir / 'family.c'
if not family_c.exists():
family_c = family_dir / 'boards' / 'family.c'
Expand All @@ -39,7 +40,7 @@ def boards(bsp):
skipped.append(family_dir)
continue
manufacturer = f_meta.get('manufacturer', '')
for board_dir in sorted(p for p in (family_dir / 'boards').iterdir() if p.is_dir()):
for board_dir in sorted((p for p in (family_dir / 'boards').iterdir() if p.is_dir()), key=lambda p: p.name):
b_meta = metadata(board_dir / 'board.h')
if not b_meta:
skipped.append(board_dir)
Expand Down Expand Up @@ -107,7 +108,7 @@ def hil_rows(entries):
def hil_md(root):
out = [HEADER, '']
for title, src in (('ci rig', 'test/hil/tinyusb.json'), ('hfp rig', 'test/hil/hfp.json')):
entries = json.loads((root / src).read_text()).get('boards', [])
entries = json.loads((root / src).read_text(encoding='utf-8')).get('boards', [])
if not entries:
continue
out += [f'### {title}', '', f'{len(entries)} boards, from `{src}`.', '',
Expand All @@ -124,14 +125,14 @@ def main():
gen_deps_doc(root / 'docs/reference/dependencies.rst')
print(f'wrote {root / "docs/reference/dependencies.rst"}')
vendors, skipped = boards(root / 'hw' / 'bsp')
(root / 'docs/reference/boards.rst').write_text(boards_rst(vendors))
(root / 'docs/reference/boards.rst').write_text(boards_rst(vendors), encoding='utf-8')
print(f'wrote {root / "docs/reference/boards.rst"}')
(root / 'docs/reference/hil_boards.md').write_text(hil_md(root))
(root / 'docs/reference/hil_boards.md').write_text(hil_md(root), encoding='utf-8')
print(f'wrote {root / "docs/reference/hil_boards.md"}')
if skipped:
print('no metadata block, left out of boards.rst:', file=sys.stderr)
for path in skipped:
print(f' {path.relative_to(root)}', file=sys.stderr)
print(f' {path.relative_to(root).as_posix()}', file=sys.stderr)
return 0


Expand Down
11 changes: 6 additions & 5 deletions .claude/skills/build/scripts/check_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -432,7 +432,7 @@ def source_lines(entry):
configuration does not satisfy is an empty translation unit, whatever built."""
argv = family_json.preprocess_argv(entry) + ['-E', entry['file']]
try:
r = subprocess.run(argv, capture_output=True, text=True, cwd=entry['directory'])
r = subprocess.run(argv, capture_output=True, encoding='utf-8', errors='replace', cwd=entry['directory'])
except OSError:
return None
if r.returncode != 0:
Expand Down Expand Up @@ -659,7 +659,7 @@ def stale_options(build_dir, supplied):
def roster_variants(boards, config):
"""(board, build name, defines, cflags) for each variant hil_test.py runs of each board."""
try:
roster = {b['name']: b for b in json.loads(Path(config).read_text())['boards']}
roster = {b['name']: b for b in json.loads(Path(config).read_text(encoding='utf-8'))['boards']}
except (OSError, ValueError, KeyError, TypeError) as e:
fail(f'could not read the HIL roster {config}: {e}')
unknown = [b for b in boards if b not in roster]
Expand Down Expand Up @@ -741,10 +741,11 @@ def build_one(board, examples, targets, defines, cflags, shared, fetch, verbose,

def run(cmd, verbose):
# streamed as it arrives: a variant build runs for minutes, and a silent buffer reads as a
# stall. PYTHONUNBUFFERED, or tools/build.py block-buffers its piped stdout until it exits
# stall. PYTHONUNBUFFERED, or tools/build.py block-buffers its piped stdout until it exits;
# PYTHONIOENCODING, or on Windows it writes the pipe in the ANSI code page, not as decoded here
out = []
with subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, cwd=ROOT,
env={**os.environ, 'PYTHONUNBUFFERED': '1'}) as p:
with subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, encoding='utf-8', errors='replace', cwd=ROOT,
Comment thread
hathach marked this conversation as resolved.
env={**os.environ, 'PYTHONUNBUFFERED': '1', 'PYTHONIOENCODING': 'utf-8'}) as p:
for line in p.stdout:
out.append(line)
if verbose:
Expand Down
40 changes: 32 additions & 8 deletions .claude/skills/build/scripts/coverage_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class or port driver whose CFG_* guard compiled its body away) contributes nothi
historical tree, since the current graph cannot know what built the deleted file.
"""
import argparse
import importlib.util
import json
import os
import re
Expand All @@ -37,6 +38,11 @@ class or port driver whose CFG_* guard compiled its body away) contributes nothi
from pathlib import Path

ROOT = Path(__file__).resolve().parents[4]
# split_command, from this script's checkout under a private name: graph's `import build` must
# still get the audited checkout's own family_json
_spec = importlib.util.spec_from_file_location('_coverage_audit_family_json', ROOT / 'tools' / 'family_json.py')
family_json = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(family_json)
DEPS_HEADER = re.compile(r'^(\S.*): #deps \d+')
ROLES = ('device', 'host', 'dual', 'typec')
FORMAT = 3
Expand All @@ -46,11 +52,20 @@ class ExtractError(Exception):
pass


# Windows: the paths rel() found on another drive than the checkout, outside it as a '..' is on
# POSIX; graph counts them per board on stderr, so a checkout split across drives shows up
other_drive = set()


def rel(path, root, base=None):
"""Repo-relative path of `path` (relative ones resolved against `base`, default `root`),
or None for a file outside the checkout (toolchain headers) or a generated one."""
p = os.path.normpath(path if os.path.isabs(path) else os.path.join(base or root, path))
r = os.path.relpath(p, root)
try:
r = Path(os.path.relpath(p, root)).as_posix()
except ValueError: # Windows: another drive (a toolchain header) is outside
other_drive.add(p)
return None
return None if r.startswith('..') or r.startswith('cmake-build') else r


Expand Down Expand Up @@ -112,7 +127,7 @@ def linker_includes(script, search_dirs, seen=None):
and the -L dirs - plus the including script's own directory."""
seen = set() if seen is None else seen
try:
with open(script, errors='replace') as fh:
with open(script, encoding='utf-8', errors='replace') as fh:
text = fh.read()
except OSError:
return seen
Expand Down Expand Up @@ -199,7 +214,7 @@ def scope_cmake_inputs(files, examples):


def run(cmd, cwd, **kw):
return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, **kw)
return subprocess.run(cmd, cwd=cwd, capture_output=True, encoding='utf-8', errors='replace', **kw)


def run_ok(cmd, cwd):
Expand All @@ -219,14 +234,15 @@ def is_live(obj_path, compiler, cwd):
if '__gnu_lto_slim' not in r.stdout:
return defines_symbols(r.stdout)
try:
n = run([compiler + '-nm', obj_path], cwd)
nm = compiler[:-4] + '-nm.exe' if compiler.lower().endswith('.exe') else compiler + '-nm'
n = run([nm, obj_path], cwd)
except OSError:
return True
return n.returncode != 0 or nm_defines_symbols(n.stdout)


def compiler_of(entry):
return entry['arguments'][0] if 'arguments' in entry else entry['command'].split()[0]
return entry['arguments'][0] if 'arguments' in entry else family_json.split_command(entry['command'])[0]


def extract(build_dir, root, example=None):
Expand All @@ -235,11 +251,11 @@ def extract(build_dir, root, example=None):
its CMake inputs are its own too. `nodeps` lists live objects ninja recorded no deps
for: only their source is known."""
src_of = {}
with open(os.path.join(build_dir, 'compile_commands.json')) as fh:
with open(os.path.join(build_dir, 'compile_commands.json'), encoding='utf-8') as fh:
cc = json.load(fh)
for e in cc:
out = e['output'] if os.path.isabs(e['output']) else os.path.join(e['directory'], e['output'])
src_of[os.path.relpath(out, build_dir)] = e
src_of[Path(os.path.relpath(out, build_dir)).as_posix()] = e # as ninja names it
targets = run_ok(['ninja', '-C', build_dir, '-t', 'targets', 'all'], root)
known = {line.split(':', 1)[0] for line in targets.splitlines()}
deps = parse_ninja_deps(run_ok(['ninja', '-C', build_dir, '-t', 'deps'], root))
Expand Down Expand Up @@ -272,7 +288,11 @@ def dep_files(obj):
s.add(f)
continue
a = os.path.normpath(os.path.join(build_dir, d))
for t in (d, os.path.relpath(a, build_dir), a):
try:
r = Path(os.path.relpath(a, build_dir)).as_posix()
except ValueError: # Windows: on another drive than the build, so no relative ninja name
r = d
for t in (d, r, a):
if t in known:
s |= generated_inputs(t)
break
Expand Down Expand Up @@ -365,6 +385,7 @@ def cmd_graph(a):
name = f'audit-{os.getpid()}-{board}'
build_dir = os.path.join(root, 'cmake-build', f'cmake-build-{name}')
shutil.rmtree(build_dir, ignore_errors=True)
other_drive.clear()
try:
r = run([sys.executable, 'tools/build.py', '-b', board, '--build-name', name], root)
rec = {'format': FORMAT, 'head': head, 'board': board, 'config': 'default',
Expand All @@ -378,6 +399,9 @@ def cmd_graph(a):
finally:
shutil.rmtree(build_dir, ignore_errors=True)
rec['secs'] = round(time.monotonic() - t0, 1)
if other_drive:
print(f'{board}: {len(other_drive)} path(s) on another drive than the checkout counted as outside it, '
f'e.g. {min(other_drive)}', file=sys.stderr)
with open(path, 'w') as fh:
json.dump(rec, fh)
print(f'{board}: {rec["status"]} {rec["secs"]}s '
Expand Down
7 changes: 7 additions & 0 deletions .claude/skills/hil-contract/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ python3 .claude/skills/hil-contract/scripts/hil_remote.py -b raspberry_pi_pico2

An agent launches it with `--run-id` and waits on it as Timing says.

It refuses, before touching the rig, when no `rsync` is on PATH. On a Windows dev PC that must be
an MSYS2 or Cygwin `rsync.exe` in an absolute PATH dir, and it must find its own distribution's
`ssh` first on PATH, since with Windows OpenSSH (`System32\OpenSSH`) the argument conversion and
the pipes rsync relies on break. There it also refuses a config or firmware dir on another drive
than the checkout, which rsync cannot name, and snapshots receipt-pinned firmware under
`.hil-remote/`. The Windows path is not yet proved end to end on a rig.

One invocation per board is wrong here, not merely slow: each run `rm -rf`s `REMOTE_DIR`
and rewrites the report, so only the last board's rows survive. A second run sharing
`REMOTE_DIR` is refused, before the wipe, while the first holds `<REMOTE_DIR>.lock`. The rig
Expand Down
Loading
Loading