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
- Never run
kicad-cli,ngspiceor theedaCLI on the host. Use./bin/eda.sh <command>— it builds and runs the container, maps your uid, and keeps the host clean. Anything else pollutes the host or fails outright. - Paths passed to
./bin/eda.share relative to the repository root (mounted at/workin the container). KICAD_VERSIONselects the KiCad release; the default is inbin/eda.shand theMakefile. Keep the two in sync when changing it.
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)
- Python dependencies: edit
pyproject.toml, thenmake lock(uv lock).uv.lockis the only lock file — it carries the hashes and the image installs it withuv sync --frozen. Do not add a second requirements file. - Build backend:
[build-system] requiresis resolved outsideuv.lock, so it is pinned with==inpyproject.tomland mirrored by wheel hash indocker/build-backend.txt. Change both together, and keep the requirement list free of transitive dependencies —--require-hashesneeds every one of them pinned too, which is whywheelis not listed. - The uv bootstrap:
docker/uv-bootstrap.txtpins uv for this image, which is linux on two architectures, and its own header carries the command that regenerates it. Use that command. A bot bumping uv will paste every platform PyPI publishes — macOS, Windows, musl, riscv64 — and the sdist along with them; the result still builds, and with the sdist listed--require-hasheswill accept building uv from source, which is a wider thing to accept than a hash pin is for. The test caps the list at four hashes. - KiCad releases: add
<version> <sha256 digest>todocker/kicad-digests.txtbefore building with a newKICAD_VERSION. Keep the Dockerfile’s defaultKICAD_VERSION/KICAD_DIGEST, theMakefileandbin/eda.shin agreement, and add the version to theci.ymlmatrix if it is meant to be supported. - GitHub Actions:
uses: owner/action@<40-char-sha> # vX.Y.Z. No tag-only refs, noubuntu-latest. When a bot proposes a bump, verify the SHA really is that tag (git ls-remote --tags https://github.com/<owner>/<action>) before merging.
Test fixtures
tests/fixtures/example_project is a small, DRC-clean KiCad project (RC filter
- LM321 buffer) that also passes every built-in gate policy. It states what it
expects of a design: ratings and tolerances on the passives, manufacturer part
numbers on the actives, and a sheet note explaining the values. Keep it that way
— a new
spec.*orreadability.*finding there means either the rule or the fixture drifted. Its symbols come from the real KiCad libraries; its footprints are simplified, which is why DRC reportslib_footprint_mismatchfor each of them. Keep the project clean: a new error there means the toolkit changed behaviour. Its own README documents the two constraints that are easy to break by accident — no KiCad 10 only tokens, and the ground pour stays filled.
Documentation
README.md— what the toolkit is and how to use it.docs/guides/— one usage guide per area, and the source of truth for how to use the toolkit well. Read the one that matches the task before doing it:datasheet-analysis,spice-simulation,kicad-schematic-review,kicad-schematic-authoring,kicad-pcb-review,kicad-pcb-authoring,kicad-design-gate,kicad-fabrication-output,eda-environment.docs/examples/— committed sample output, regenerable with the commands documented there.
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:
- Start the file with its
#heading. The page title comes from the first heading, and only if nothing precedes it. - Never write Liquid delimiters — a doubled curly brace, or a curly brace
followed by a percent sign. Jekyll expands Liquid inside fenced code blocks
too, and silently deletes what it cannot parse: a documented command lost its
--formatargument that way. There is no per-file opt-out on the Jekyll that Pages runs, and the escape hatch would itself show up verbatim on github.com, so far every case has had a clean alternative. - Link to source files on github.com, not by relative path.
src/,tests/,docker/and friends are excluded from the site, so a relative link to them resolves on github.com and 404s on the site.
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.