Skip to content

Simplification pass: canonical C++23, modern CMake, one form per idea - #34

Merged
nehalkpatel merged 17 commits into
mainfrom
refactor/simplification
Aug 29, 2026
Merged

Simplification pass: canonical C++23, modern CMake, one form per idea#34
nehalkpatel merged 17 commits into
mainfrom
refactor/simplification

Conversation

@nehalkpatel

Copy link
Copy Markdown
Owner

A codebase-wide simplification pass against the project's stated goals: well structured, easy to follow, canonical constructs, modern C++23, modern CMake (Professional CMake 21st ed. as the reference), and docs that match behavior. Quality only — no functional features. 17 commits, each leaving the build green; both host-debug and host-release workflows pass from a clean tree.

Credibility fixes (docs vs. reality)

  • clang-tidy now actually checks headers: the -header-filter regex ended with a stray } no path could match, so every project header went unanalyzed while the docs claimed enforcement. Fixing it surfaced a wave of real diagnostics (enum bases, missing override/[[nodiscard]], a default argument on a virtual), all fixed.
  • host-clang.cmake no longer silently overrides compilers chosen in CMakeUserPresets.json, so the documented macOS/Homebrew flow can work.
  • Untracked clutter removed: src/.DS_Store, and the compile_commands.json symlink into gitignored build/ (dangling on fresh clones) — the link is now regenerated at configure time.
  • README/CLAUDE.md corrections: Python 3.14 (not 3.11+), run commands, status tables.

Build system: modern CMake core

  • Target-based usage requirements: project_options/project_warnings INTERFACE targets replace COMMON_COMPILE_OPTIONS pasted into 15 call sites and the single directory-scoped include_directories() the entire include graph rested on.
  • The project stands alone without presets: the invented CMAKE_PRESET name-string gating is gone; EMBEDDED_CPP_MCU/EMBEDDED_CPP_BOARD are declared cache variables defaulting to host, so cmake -B build -DCMAKE_TOOLCHAIN_FILE=cmake/toolchain/host-clang.cmake configures and builds. Presets are a convenience layer again, and user presets no longer restate anything.
  • FetchContent is canonical: pinned commit hashes, no deprecated GetProperties/POPULATED dance, GTest::gtest_main instead of a hand-rolled deprecated alias, misused option() fixed; ETL dropped (nothing used it).
  • enable_testing() right after project(); the Python venv is a CTest setup fixture (uv sync --frozen, a no-op once synced) instead of network I/O at configure time; integration tests locate apps via $<TARGET_FILE:...>.
  • Interface libraries declare headers via FILE_SET HEADERS; the entry point is an OBJECT library so main.o doesn't rely on archive symbol-resolution order.
  • Hardware scaffolding parked honestly: ~4,700 lines of unreachable STM32 CubeMX vendor C (never compiled; two drifted copies of the same hal_conf.h) removed — git history preserves them. ARM toolchain files and configure presets stay as the teaching artifact; configuring an ARM preset stops with a clear "backend not implemented" message instead of a raw internal error, and the broken ARM workflow/build presets are gone. armgcc.cmake defects fixed along the way (ARM Debug now really gets -O0).

C++: one canonical form per idea

  • Transact<Response>(transport, request): the encode→send→receive→decode→status-check exchange was written six times in three competing styles across the host peripherals; it's now one monadic helper and the peripherals reduce to a request literal plus a transform.
  • API honesty: Uart/I2C trimmed to what the host implements (the I2C "DMA" methods were byte-identical to the "interrupt" ones; UART async filled a buffer nothing read). Async/interrupt/DMA modes are recorded as future work for when a hardware platform can implement them with genuinely different behavior.
  • Dispatcher routes by receiver claim; the paid-for-but-unused predicate layer (five copies of the same IsJson) is gone, and HostBoard no longer constructs its dispatcher twice.
  • Apps: one RunApp<App> helper; real errors propagate to main(), which reports via std::println and returns (destructors run) instead of exit().
  • Transport: endpoint arbitration (flock + liveness probe) extracted to endpoint_lock.*; TransportConfig is an aggregate again (designated initializers work; both test workarounds deleted); history-tense comments rewritten to present tense with the history moved to the commit log.

Tests

  • Shared HostPeripheralTest infra + test_support.hpp delete ~300 duplicated fixture lines (two identical recording loggers, four copies of endpoint naming/cleanup, ~110-line copied UART/I2C fixtures).
  • test_messages round-trips all six message types via one TYPED_TEST — less code, strictly more coverage, including the custom std::byte serializer.

Python emulator

  • One Peripheral class replaces ~250 copy-pasted lines across pin/uart/i2c — fixing a real drift where UART's wait handlers silently dropped a previously installed hook.
  • The wire vocabulary is StrEnums in common.py explicitly mirroring the C++ encoder; Status covers all twelve error values (was five); PinDirection uses the wire spellings.
  • Dead API deleted; uv run host-emulator is a proper console script; imports are unconditional (if TYPE_CHECKING and from __future__ import annotations removed as project convention — PEP 649 on Python 3.14 obsoletes the rationale; ruff's TC ruleset unselected accordingly).
  • Tests use @pytest.mark.usefixtures, shared protocol constants naming their C++ source of truth, and an autouse hook-reset; mypy now covers tests/ too (uv run mypy), and unused pytest-cov is gone.

CI / Docker / docs

  • One compose service taking the workflow command at run time; CI runs as the runner's uid instead of root-then-chown; the devcontainer shares the image tag instead of rebuilding a second one; the LLVM pin is declared once.
  • py/host-emulator/README.md (was 0 bytes) is now the canonical wire-protocol document — envelope, per-object operation matrix, field sets, status codes, and the mirror rule.
  • docs/PROJECT_PLAN.md is forward-looking only; its self-contradictions (I2C both "Bump black from 24.2.0 to 24.3.0 in /py/host-emulator #1 priority stub" and complete, wrong board names, stale test counts) are resolved, with a decision-log entry for this pass.

Notes for reviewers

  • Existing local build trees need one rm -rf build/: -stdlib=libc++ now arrives via CMAKE_CXX_FLAGS_INIT in the toolchain file, which only applies to fresh caches.
  • The CI/Docker changes were validated by inspection (no docker in the authoring environment) — worth watching this PR's CI run.

🤖 Generated with Claude Code

nehalkpatel and others added 17 commits August 29, 2026 15:29
The -header-filter regex ended with a stray '}', which no header path can
match, so clang-tidy silently skipped every project header while the docs
claimed header enforcement. Fix the regex (and use PROJECT_SOURCE_DIR so
the filter stays correct if the project is ever embedded in a superbuild).

Fix the diagnostics the working filter surfaced:
- give every enum an explicit std::uint8_t base
- use 'override' on derived-interface destructors in pin.hpp
- add missing [[nodiscard]] (Dispatcher::Dispatch, HostUart query methods)
- drop the default argument on the virtual Uart::Receive (no caller used it)
- remove redundant {} member initializers
- rename nlohmann adl_serializer parameters, and suppress, with a stated
  reason, the checks that the NLOHMANN_* macro expansions cannot satisfy

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
src/.DS_Store was Finder metadata with no reason to be tracked, and
compile_commands.json was a tracked symlink into the gitignored build/
tree — dangling on a fresh clone until the exact 'host' preset was
configured, and wrong for every other preset.

Ignore both, and have configure recreate the symlink pointing at the
build tree configured last, which is what IDE tooling actually wants.
Narrow the *.pdf ignore to the one reference book (plus its extracted
text) so real documentation can't be silently swallowed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The toolchain file set CMAKE_{C,CXX,ASM}_COMPILER with plain set(), which
shadows the cache variables a CMakeUserPresets.json entry provides — so
the documented macOS/Homebrew flow silently fell back to clang-18, which
does not exist there. Guard each compiler assignment with
if(NOT DEFINED ...) so an explicit choice always wins and
HOST_TOOLCHAIN_PATH/-VERSION remain the fallback.

Drop CMAKE_LINKER from the example presets (it names a linker, not the
compiler driver; CMake links through the compiler) and the two custom
tool variables nothing consumed. LLVM_COV_PATH/LLVM_PROFDATA_PATH stay:
the code-coverage module reads them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- external/ held only an empty CMakeLists.txt yet was wired into the
  build and into three coverage-exclusion patterns
- src/libs/board/nrf52832_dk/ contained a single 0-byte CMakeLists.txt
  and was referenced by nothing
- test/ held no targets, only a placeholder comment (now in CLAUDE.md's
  Testing section) and byte-identical copies of src/'s .clang-format
  and .clang-tidy
- py/CMakeLists.txt was a pure pass-through; the root now adds
  py/host-emulator directly
- remove a '# board.cpp)' remnant and two commented-out statements in
  host_pin.cpp whose explanation already lives in the adjacent prose

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pyproject.toml pins requires-python >=3.14 and .python-version selects
3.14; a reader following the README's 3.11+ would install a Python that
uv sync rejects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The STM32 CubeMX vendor trees (~4,700 lines of generated C, linker
scripts, and startup assembly) were never compiled: the ARM presets
cannot configure while only the host MCU backend exists, and the two
copies of stm32f3xx_hal_conf.h had already drifted apart. Remove them;
git history preserves them for when hardware work starts, and
regenerating from CubeMX will be fresher then anyway.

What remains is the honest teaching artifact: the ARM toolchain files
and hidden configure presets. Configuring an ARM preset now stops with
a message naming the unimplemented backend instead of a raw
add_subdirectory error, and the broken ARM build/workflow presets that
advertised runnable builds are gone.

Toolchain cleanups in armgcc.cmake:
- move warning policy out (a project decision, not a toolchain one)
- fix the CMAKE_CXX_ASM_FLAGS_DEBUG_INIT typo so ARM Debug gets -O0
- drop the undefined LD_FLAGS expansion, the manual CMAKE_CROSSCOMPILING
  (derived from CMAKE_SYSTEM_NAME), unused tool variables, and
  commented-out code
- add FIND_ROOT_PATH_MODE PROGRAM/LIBRARY so cross builds cannot pick
  up host programs or libraries

Preset cleanups: drop the empty include list, the no-op hidden 'arm'
preset, the duplicated test-preset execution block, and the stm32cubef7
dependency declaration nothing could reach. CMAKE_RUNTIME_OUTPUT_DIRECTORY
moves from the presets into the top-level CMakeLists.txt where build
layout belongs. Docs updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the three ambient mechanisms the build rested on with explicit,
target-scoped ones:

- COMMON_COMPILE_OPTIONS (pasted into 15 target_compile_options calls)
  and the directory-scoped include_directories(src) become two INTERFACE
  targets: project_warnings (warning policy, with generator expressions
  for the compiler-conditional parts) and project_options (src/ include
  root, -fno-rtti, links project_warnings). Every target now names its
  dependency instead of inheriting directory state.
- Gating on the invented CMAKE_PRESET name string is gone. EMBEDDED_CPP_MCU
  and EMBEDDED_CPP_BOARD are declared cache variables defaulting to host,
  host-only dependencies key off the MCU backend, and tests key off
  BUILD_TESTING — so a plain 'cmake -B build
  -DCMAKE_TOOLCHAIN_FILE=cmake/toolchain/host-clang.cmake' configures and
  builds with no preset at all, and inheriting user presets no longer
  need to restate anything.
- The global CMAKE_CXX_FLAGS append of -stdlib=libc++ moves to the host
  toolchain file as CMAKE_CXX_FLAGS_INIT, the supported home for a
  global ABI decision (it must reach FetchContent-built dependencies
  too, which no target property can).

Also, in the same spirit:
- include(CTest) sits right after project(), so no add_test can be
  silently discarded; py/ integration tests are added only for host
- interface libraries declare their headers via FILE_SET HEADERS (which
  carries the include root as a usage requirement and readies install
  rules); the inert LINKER_LANGUAGE properties on them are gone
- the venv for the Python tests is now a CTest setup fixture running
  'uv sync --frozen' (a no-op when already synced) instead of an
  execute_process at configure time, so configure is fast and offline;
  cmake/python_venv.cmake is folded into py/host-emulator/CMakeLists.txt
- integration tests locate apps via $<TARGET_FILE:...> instead of
  hand-building output paths
- FetchContent: the deprecated GetProperties/POPULATED dance is a plain
  MakeAvailable; the misused option(CPPZMQ_BUILD_TESTS) is a proper
  cache set; the duplicate googletest MakeAvailable and the hand-rolled
  deprecated GTest::GTest alias are gone (tests link GTest::gtest_main);
  GIT_TAGs pin commit hashes with the tag name in a comment; ETL is
  dropped entirely — nothing in src/ used it
- sys (the entry point) is an OBJECT library so main.o links into each
  app directly instead of relying on archive symbol-resolution order
- format.cmake uses PROJECT_SOURCE_DIR throughout
- the root CMakeLists.txt is organized into labeled sections
  (setup / dependencies / tooling / targets)

Existing build trees need a fresh configure (rm -rf build/) because
-stdlib=libc++ now arrives via *_INIT, which only applies to new caches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The encode -> send -> receive -> decode -> status-check exchange was
written six times across the three host peripherals, in three different
styles: host_pin chained and_then, host_uart mixed a chain with a manual
if-cascade inside the last lambda, and host_i2c wrote the whole thing
imperatively. In a codebase meant to be copied from, the same protocol
step written three ways is three competing house styles.

Transact<Response>(transport, request), next to Encode/Decode, is now the
single canonical exchange — including folding the emulator's status field
into the error channel — and the peripherals reduce to a request literal
plus a transform of the response. This also makes GetState honor the
response status, which the hand-rolled version forgot.

Along the way, in the same files:
- Decode takes std::string_view by value (a const& to a view is an
  anti-idiom, and two callers were building a std::string just to bind
  the reference), parses via the non-throwing overload, and documents
  the one remaining catch at the nlohmann boundary; string_view-by-value
  is now used across Receiver, Dispatcher, and the predicates
- Receiver gains the virtual destructor every other interface already
  had, plus a comment stating its 'unexpected means not mine' contract
- request/response literals rely on the message structs' defaulted type
  and object fields, as host_pin already did
- HostPin's input-direction guard moves into SendState, the funnel all
  three setters share; Toggle becomes the same and_then chain as its
  neighbours
- the Error<->JSON table now covers all twelve enumerators (it silently
  covered five) and says where its Python mirror lives
- the stray semicolon after Encode is gone

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The interfaces promised transfer modes that nothing implements: the host
I2C's SendDataDma was byte-identical to SendDataInterrupt (both just
invoked the callback on the blocking result), and the host UART's
IsBusy/Available/Flush returned constants while SendAsync/ReceiveAsync
filled a receive_buffer_ member that nothing ever read. Four tests
existed solely to prove the identical wrappers behaved identically.

Keep what the host genuinely implements — blocking SendData/ReceiveData
on I2C, and Init/Send/Receive/SetRxHandler on UART — and delete the rest,
including HostUart's now-unused busy_/callback/buffer state and the
async-response half of its dispatcher Receive path. The interface docs
and docs/PROJECT_PLAN.md record async/interrupt/DMA modes as future
work, to be reintroduced when a hardware platform can implement them
with genuinely different behavior worth teaching.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three apps were the same file three times: identical headers modulo
the class name, and identical AppMain bodies that discarded the real
error by returning kUnknown — teaching a reader to throw away error
information at exactly the layer that reports it. AppMain is now a
one-line RunApp<App>(board) call (app.hpp) that propagates the first
real error from Init or Run.

Blinky::Run's loop dressed a no-op in monadic ceremony — the status was
reassigned each iteration and never inspected, and its or_else was the
identity on the error channel. It now reads as the two checks it is.
The unreachable 'return {}' after each app's infinite loop is gone.

main() reported failure through iostream and left via exit(), which
skips destructors — so the transport's careful shutdown path never ran
on failure. It now returns (destructors run), reports the actual error
code via std::println, and keeps a documented top-level catch: project
code is exception-free, but the host build links libraries that throw
(cppzmq, nlohmann-json), and main is where an escaped exception becomes
a failing exit code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReceiverMap paired every receiver with a routing predicate, but the only
predicate ever installed — five times over, from three copies of the
same IsJson helper — was a JSON sniff that told the dispatcher nothing
about which receiver owned the message. The real routing already lived
inside each receiver, which decodes the message and checks the addressed
name. So the predicate layer was paid for and unused.

ReceiverMap is now just the ordered receivers; Dispatch offers the
message to each until one claims it (the Receiver contract's 'unexpected
means not mine'), and IsJson is gone from all three sites.

HostBoard no longer builds its Dispatcher twice: the dispatcher observes
the receiver map by reference, so it can be a plain member constructed
up front while Init() fills the map after the components exist — which
also retires the six-step narration that explained the double
construction.

The dispatcher tests now exercise the claim contract with a
NamedReceiver that accepts only its own messages (storing what it
received by value — the old fixture kept a string_view into a dead
temporary), the two which-receiver-wins cases collapse into one
parameterized test, and the empty do-nothing fixture is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ents

ZmqTransport carried ~130 lines of POSIX endpoint arbitration — flock
ownership, an AF_UNIX connect(2) liveness probe, and their rationale —
inline in the class a learner reads to understand 'how does the C++ side
talk to the emulator'. That machinery now lives in its own translation
unit (endpoint_lock.hpp/.cpp) with its prose intact, and ServerThread's
bind phase reads as the three lines of intent it always was: take the
lock, probe for a foreign owner, bind.

TransportConfig is an aggregate again. Its Logger& member forced two
user-provided constructors, which killed designated initializers — the
idiom the rest of the codebase leans on — and both transport test files
had grown a MakeConfig helper with an apologetic comment to work around
it. The member is now a reference_wrapper defaulting to DefaultLogger(),
callers write TransportConfig{.send_timeout = ..., .logger = ...}, and
the workarounds are gone.

Comments that narrated the code's history in past tense are rewritten to
describe present behavior; the history lives here in the log instead.
The 'reduce cognitive complexity' apology over the Log helpers is gone —
they stay on their merits as brevity.

Also: delay.hpp puts #pragma once first, names its parameter duration
(callers pass milliseconds; only the conversion target was micro), and
documents the blocking semantics; transport.hpp loses its empty private
section and gains [[nodiscard]] and an interface comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The UART and I2C test fixtures were ~110 near-verbatim duplicated lines
each — socket/thread lifecycle, dispatcher/transport wiring, readiness
probe, teardown ordering, endpoint cleanup — differing only in the ~25
lines of protocol logic. Any lifecycle fix had to be made twice, and the
explanatory comments were already duplicated prose. They now derive from
HostPeripheralTest (host_peripheral_test_infra.hpp), which owns all of
that and asks a concrete fixture for exactly two things: construct the
peripheral (MakeReceiver) and play the emulator's side of one exchange
(HandleRequest). The redundant 'ret == 0 then ret <= 0' double-check in
the copied poll loops collapses to one condition.

test_support.hpp consolidates the rest of the copied scaffolding:
- one RecordingLogger (Count + Contains) replaces two byte-identical
  logger classes that differed only in their query method
- MakeEndpoint/EndpointPath/RemoveEndpointArtifacts replace four copies
  of per-process endpoint naming and of the socket+.lock cleanup loop

test_messages now round-trips all six message types through one
TYPED_TEST instead of testing one type three times — strictly more
coverage (the std::byte adl_serializer path included) in less code —
keeps a wire-format pin for the exact JSON the Python emulator parses,
and adds the valid-JSON-wrong-shape decode case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three peripheral modules were written by copy-paste: handle_message,
handle_response, and the setter pair were near-identical three times
over, and the 12-line install/restore wait ritual appeared seven times —
already drifted, since the pin and I2C wait handlers chained the
previously-installed hook while the UART ones silently dropped it, so a
nested UART wait lost the outer callback. All of that now lives once in
Peripheral (peripheral.py): message routing keyed on a class-level
OBJECT_TYPE, the request/response hooks, _transact for the blocking
request/reply exchange (pin set/get and uart send_data were the same
sequence three times), and _wait_for(predicate, timeout), which chains
by construction. The seven wait_for_* methods collapse to predicates.

The wire vocabulary — Request/Response, Pin/Uart/I2C, Set/Get/Send/
Receive, and the status codes — is now a set of StrEnums in common.py
that name emulator_message_json_encoder.hpp as their C++ mirror. Status
covers all twelve C++ Error values instead of five; PinDirection uses
the wire spellings Input/Output instead of an IN/OUT that disagreed
with the C++ table; and since a StrEnum serializes as its value, the
fifteen '.name' call sites disappear. PinState/PinDirection follow suit.

Deleted, as called by nothing anywhere: the five name-lookup wrappers on
DeviceEmulator (uart_initialized, get_uart_tx_data, clear_uart_tx_data,
uart_send_to_device, get_pin_state — tests use the typed accessors),
I2C.read_from_device, and set_on_response on all three peripherals.

DeviceEmulator routes through one dict + _dispatch instead of an
if/elif chain feeding three near-identical _handle_* methods, and
detects JSON by parsing it (json.JSONDecodeError) rather than sniffing
braces. A pin Set from the device is now honored only for output pins —
the emulator, not the device, drives inputs — matching the C++ side's
guard.

main() no longer sends a stray 'Hello' probe and blocks on the reply
(scaffolding from before the README pointed users at it); it just runs
the emulator until Ctrl-C, sets up logging there instead of at import
(a library import should not mutate global logging state), and is
installed as the 'host-emulator' console script the README now invokes.

Imports are unconditional throughout: no 'if TYPE_CHECKING' guards, and
no 'from __future__ import annotations'. The guard splits a module into
two import graphs — one the type checker sees, one that exists at
runtime — hiding real dependencies and defeating runtime annotation
introspection, and its legitimate uses (import cycles, heavy optional
dependencies) do not occur in this package. On Python 3.14, which this
project requires, PEP 649's lazily evaluated annotations remove the
runtime-cost rationale for both idioms. ruff's flake8-type-checking
ruleset ('TC') is unselected accordingly — it exists to enforce exactly
that pattern — with the convention recorded in pyproject.toml.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nfig

Test cleanups:
- the eleven '_ = fixture  # ensure X is running' lines become
  @pytest.mark.usefixtures marks, and the two vacuous 'is not None'
  asserts on fixtures are gone (a fixture returning None fails at setup)
- conftest imports endpoint_path from the package instead of carrying a
  verbatim copy, types the application-fixture factory instead of
  returning Any, and reports a missing executable via pytest.fail
  rather than a bare assert (stripped under python -O)
- an autouse fixture uninstalls any on_request/on_response hooks a test
  left installed — the silent cross-test contamination module-scoped
  fixtures allow. Buffers stay explicitly managed by the tests that
  care: some sharing is deliberate (the UART greeting arrives once, at
  app start), so a blanket reset would be wrong
- the I2C address/pattern and UART greeting literals copied from the
  C++ apps are module constants naming their source of truth

Tooling:
- pytest-cov is removed: nothing wired it up (coverage is the C++
  llvm-cov pipeline), so it was installed into every venv for nothing
- mypy gets files = [src, tests], so 'uv run mypy' with no arguments
  checks both trees — CI previously checked only src while tests/ ran
  unchecked; CI and CLAUDE.md shorten accordingly
- the pythonpath entry gains the one-line comment saying why it exists
  alongside the editable install

DeviceEmulator grows all_peripherals() so tests reset hooks through a
public accessor rather than reaching into the routing table.

The tests follow the package's no-TYPE_CHECKING convention: imports are
unconditional, and conftest's AppFixture alias is a PEP 695 'type'
statement (lazily evaluated by design) rather than a quoted alias
inside a guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The host-debug and host-release compose services were a third naming
layer over workflow presets that already exist and needed extending by
hand for each new workflow; the one embedded-cpp-dev service now takes
the workflow command at run time, which is also how CI invokes it.

CI ran the container as root and then chown'd the coverage output —
repairing the ownership running as root had just broken. It now runs as
the runner's own uid/gid with a writable HOME for uv's cache, so
nothing needs repairing. The compose file documents that UID/GID must
be exported explicitly (bash does not export its UID shell variable, so
the ${UID:-1000} fallback was silently taken everywhere).

The devcontainer no longer retags the image: one tag from one
Dockerfile, so a devcontainer build serves docker compose run too
instead of the two shadowing and rebuilding over each other.

The LLVM major version is declared once as a Dockerfile ARG; format.sh
reads CLANG_FORMAT_MAJOR with the same default, and the remaining
references cross-reference the pin instead of each hardcoding it with
its own reminder comment.

Running CI as a non-1000 uid exposed the seam between the image's two
uses: the devcontainer base image creates /home/vscode with mode 750, so
any uid but vscode's cannot traverse into /home/vscode/workspace at all
(CMake reported the presets file as "not found"; root had been masking
it). Rather than loosen the devcontainer's home directory, CI gets its
own compose overlay -- the batch counterpart of .devcontainer's -- that
mounts the checkout at /workspace and sets a writable HOME. One image,
two uses, each with its own overlay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
py/host-emulator/README.md was a 0-byte file — and the JSON protocol,
which is the project's entire teaching point, was documented nowhere: it
existed only as string literals split across the C++ encoder and three
Python modules. The README is now the canonical protocol document
(envelope, per-object operation matrix, field sets, status codes) and
states explicitly that the C++ and Python vocabularies mirror each
other. CLAUDE.md points at it instead of restating the contribution
steps without the contract.

PROJECT_PLAN.md described a slightly different project than the one in
the repo: I2C was simultaneously '#1 priority, stub needs completion'
and marked complete; i2c_demo was missing from the shipped-apps list;
milestone 2 targeted the Nucleo but its success criterion named the
Discovery; the test-count claim was arithmetically wrong on its face
and stale besides. The plan is now forward-looking only — milestones,
priorities, open debt, decision log — and defers current status to the
README and 'ctest -N', which cannot go stale. The status that was
tracked in five overlapping places now lives in one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@nehalkpatel
nehalkpatel force-pushed the refactor/simplification branch from e153dfb to 0f1f962 Compare August 29, 2026 16:54
@nehalkpatel
nehalkpatel merged commit 4a2cba7 into main Aug 29, 2026
1 check passed
@nehalkpatel
nehalkpatel deleted the refactor/simplification branch August 29, 2026 16:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant