Working in this repository

eda is a command-line toolkit for circuit design — datasheets, SPICE simulation, KiCad schematic and board review, fabrication output — implemented as the eda_toolkit Python package and executed inside a container.

This file is the working agreement for anyone changing the code: humans, and coding agents alike. (CLAUDE.md points here; there is one copy, not two.)

Ground rules

Development

make build        # build (or rebuild) the image
make test         # full suite in the container - this is the gate
make test-host    # fast pure-python subset (needs .venv with the test extra)
make smoke        # end-to-end run of every top-level command

make test mounts the working tree and puts /work/src on PYTHONPATH, so it tests the code you are editing, not the copy baked into the image. Rebuild the image only when the Dockerfile or the dependencies change.

CI runs the suite against every KiCad version in the ci.yml matrix (currently 10.0.4 and 9.0.9). Run make test KICAD_VERSION=9.0.9 before pushing anything that touches kicad_cli.py, the fixtures or the review rules. Not every flag exists in every release: gate on kicad_cli.supports([...], "--flag") rather than on a version number, and keep the fixtures in the oldest format the matrix covers — KiCad never reads a file newer than itself.

Adding a review rule

Rules are functions registered with the @rule decorator in src/eda_toolkit/kicad/sch_review.py or pcb_review.py. They receive a context (parsed design + netlist/board + ERC/DRC output) and return Finding objects. Add the rule, then add a test in tests/test_sch_review.py / tests/test_pcb_review.py using the in-memory ReviewContext.from_netlist / PcbContext.from_board constructors — no filesystem needed.

Keep severities honest: error = the design is broken, warning = a human must judge it, info = context. A rule that reads the drawing rather than the netlist (grid, overlap, page) belongs in the readability.* family; one about what a part has to state to be orderable belongs in spec.*. Anything tunable goes in the module’s THRESHOLDS dict rather than as a literal in the rule, so --threshold key=value reaches it.

Every rule id also needs an entry in the module’s RULE_SPEC, saying what condition produces it, the severity it reports and the threshold that tunes it. That table is what eda gate --list-rules prints, and tests/test_rule_spec.py parses the rule sources to enforce it in both directions — an id with no entry, an entry no rule emits, a threshold no rule names, or a policy pattern matching no rule, all fail. It also checks that the guides only name rules that exist.

Then decide what the rule does to eda gate. A rule that describes the design rather than faulting it (board.size, layout.ground_plane) must be added to CONTEXT_RULES in src/eda_toolkit/gate.py, or a policy could promote it into an error no correct design can clear. A rule a generated design has to get right belongs in _AI_BLOCKING in the same file. tests/test_gate.py covers both.

Regenerating the worked examples

tools/make_examples.py builds both variants of every design in examples/. Routing is what it spends its time on: the FPGA board is a 48-pin QFN escaped on two layers, which is the hardest board in the set. All five examples are two-layer, and are meant to stay that way: layer count is the one board parameter that changes the price of a prototype run outright, so a design that will not close on two layers grows a few millimetres before it grows a stack. Cold regeneration of the five examples takes tens of minutes on CI, and a net that finds no room sends the routing pass round again.

So the routed copper is cached under .cache/routes/ (git-ignored), keyed by everything the router reads — the outline, the parts and their pads, every stated track (including its fixed-layer intent) and via, the footprint library definitions, the nets a design names in priority_nets, and the source of tools/autoroute.py, _route_all and resolve_routes themselves. Editing where a designator prints or how a legend picks its side does not move copper, so those runs reuse the answer and finish in seconds; editing the router, or the order it offers the links in, invalidates every answer it ever gave. --no-route-cache routes from scratch.

That order has two classes. A link wider than the board’s thinnest, or on a net the design names in priority_nets, has something to lose — current, a clock, a bus — and is routed while the board is empty; nothing routed later may push it aside, so a plain link that fails or tours is promoted only to the front of the plain links and goes round. Within a class it is shortest first. A plain link that still has no lane from the front of its class is lifted ahead of the priority nets and the log says so: that is a floorplan with no room for it, which is the placement’s problem to fix, not a reason to declare the net special.

The golden CI matrix uses KiCad 9.0.9 for generation, gates and renders. Each example runs independently with fail-fast disabled, so a failed or slow FPGA iteration does not suppress the other four designs’ evidence. Each job also regenerates from the populated route cache and compares the result with the cold run: a cache hit must preserve the same board, not merely a passing gate. Use --only motor-driver (or another example name) for a targeted iteration, --route-cache-dir <directory> to isolate its cache, and --require-route-cache to fail rather than silently routing on a cache miss. --no-route-cache ignores both cached tracks and learned order but writes its fresh result for a later cache-hit check. The golden job additionally runs tools/check_example_contracts.py over the generated designs and gate JSON; keep these explicit electrical, plane and negative-control requirements in sync with any intentional change in the examples’ specification. Both version-matrix jobs gate the five checked-in reviewed designs as well, and run the same contracts with --reviewed-only over those verdicts. The fixture test suite alone is not evidence that every example passes both KiCad versions.

The rip-up order is kept separately, in <design>.order.json, and survives a change that does invalidate the cache: it is what an afternoon of rip-up attempts learned, and starting from it is usually the difference between seventeen attempts and none.

Both the cache and that order are git-ignored, so the copper checked in here has to be what a cold route finds — the drift check compares the two. On the FPGA board a different starting order finds a different valid solution, so a board regenerated from a warm local cache can pass every gate here and still fail CI. Before committing a change that moves that board’s copper, reproduce the CI conditions: tools/make_examples.py build/golden --no-route-cache --only fpga-audio --route-cache-dir build/golden-cache, and commit what that writes.

CI proves that once per question rather than once per push. The golden job asks --route-digest for the route-cache key — which builds the design and hashes what the router reads, without routing — and keys a GitHub cache on it. A miss routes cold, exactly as above, and keeps the answer; a hit reuses it, because the entry was written by a cold route of the identical question and re-deriving it proves nothing. Change the router, a footprint, a part’s position or a stated track and the key changes with it. The learned order is deleted before the cache is saved: a cold route that starts from one is not a cold route. This is what keeps a round that moves only silkscreen at minutes rather than the hour and a quarter the FPGA board’s cold route costs.

tools/example_images.py re-renders the pictures examples/README.md shows from the regenerated projects (sheet at 150 dpi, board at 300 dpi, as JPEG). It leaves the *-first.jpg first editions alone; nothing regenerates those.

Tuning a rule

tools/review_demos.py runs both reviews over the 18 KiCad demo projects in the image and aggregates the findings per rule. Use it before and after changing a rule: a rule that fires thousands of times across that corpus is noise, however correct each instance is. Findings that repeat more than COLLAPSE_LIMIT times are folded into one entry by util.collapse_findings, so prefer grading a rule (warning vs info) over deleting it.

Pinning rules (enforced by tests/test_pinning.py)

Test fixtures

tests/fixtures/example_project is a small, DRC-clean KiCad project (RC filter

Documentation

When behaviour changes, update the guide that covers it in the same commit. A guide that describes a flag the CLI no longer has is worse than no guide.

These same files are the website: GitHub Pages serves main / (root) with the settings in _config.yml — no build workflow, no gh-pages branch, nothing generated. That puts three constraints on anything published (README.md, AGENTS.md, docs/**), all enforced by tests/test_docs.py:

make site renders it locally with the same pinned gem set Pages uses, which is how those three were found in the first place.

The shell around that Markdown is four small files, and nothing in them generates content:

File What it is
_layouts/default.html header, sidebar, footer and the client-side “on this page” list, shadowing the copy jekyll-theme-primer ships
assets/css/style.scss imports the theme (so the body still renders like github.com) and adds the shell plus a dark palette
_data/nav.yml the sidebar and the small-screen header nav
assets/logo.svg, assets/favicon.svg the mark, in the header, in the README heading and as the favicon

Adding a guide means adding it to _data/nav.yml as well as to the guides index — tests/test_docs.py fails otherwise, the same way it does for the index. Every entry names both the Markdown file it points at and the URL Jekyll publishes it under, and the test checks that the two agree and that the target is a page the site actually serves.

bin/install-skills.sh renders docs/guides/ into the tool-neutral Agent Skills layout (.agents/skills/<name>/SKILL.md) and Claude Code’s compatibility layout (.claude/skills/<name>/SKILL.md). This checkout git-ignores both generated layouts; a parent project using the submodule must configure its own ignore patterns or track them. Never edit those copies: make skills regenerates them.