Skip to content

A PCB as code, and proving its checks work

This page covers how the main board of auspex, an open 5x12 Hall-effect keyboard, is built as generated code instead of drawn in the KiCad GUI, and how the checks on that code were themselves tested. It is part of the series that starts at the auspex hub; the order that shipped the result is the JLC 4-layer PCBA guide, and the case built from the same geometry module is the build123d case guide.

Every number here is the project’s own result, taken from its repo (working name gridlock until the 2026-11-05 rename) between 2026-11-03 and 2026-11-05, on kicad-cli 10.0.8 and the kicad/kicad:10.0.8 CI image. Rev 1 of the board was ordered on 2026-11-04, so the violations quoted for it are on the board as fabricated. KiCad, Freerouting, JLCPCB and kicad-druid behaviour is cited to their public pages; where the project measured something that disagrees with a public page, the evidence table says so.

TL;DR:

  • The analog schematic sheets, PCB placement, the switch plate and the case read one geometry module (geom.py, which re-exports the per-board numbers in variant.py); the power/USB and MCU sheets read variant.py directly. The generators rewrite the committed files byte for byte, and CI fails when they do not (make repro). Routing is the exception: Freerouting is multithreaded, so the routed board is a committed artefact.
  • Eight gates check the board, plus a keyboard.json schema check and a strict plate DRC: ERC and DRC through one JSON gate with a counted baseline, a netlist contract, a placement verifier plus an independent audit that does not import it and re-derives each expectation from geom and the board’s pads, a CPL rotation check against JLC’s own footprints, a live BOM check against JLC’s parts API, and the regeneration diff.
  • A custom .kicad_dru turned a reviewer’s “USB pair routed ~5 mm apart” into DRC failures: three gap values each reported twice, 32 mm uncoupled and 2.9 mm skew. On kicad-cli 10.0.8 a rule file with any compile error is dropped whole, with no message and an unchanged exit code, so the gate requires an always-firing sentinel rule to fire exactly once.
  • Every placement, netlist, keyboard.json, DRC and BOM gate was shown to fail on a fault injected into a copy; the CPL rotation check has unit tests and one manual mutant, and the regeneration diff has no fault test. The first suite collected 88 tests: 53 fault and control tests and 25 geometry/variant invariants passing, plus 10 strict-xfail rows, each a fault some gate missed. After the fixes it ran 88 passed, 0 xfailed; with the DRC-gate rows added later it runs 107 passed.
  • Five reviewers and a judge raised 17 findings on top of green gates: 10 confirmed, 2 rejected, 1 partly rejected, the rest accepted or downgraded. One rejected blocker was a reviewer repeating a stale doc claim without reading the file it described.

geom.py(+ variant.py)gen_schem.pygen_analog.pyplace.pygen_plate.pyplatecase.pycase STEP/STL.kicad_schnetlist(kicad-cli)pcb_from_netlist.pykeyboard.jsonmux matrixgen_keyboard_jsonroute.pyFreerouting 2.5.0zones as file textkicad-cli refill.kicad_pcb(routed, committed)board file

The same pipeline as a list:

  1. geom.py holds key centres, the mux assignment, outline, mount holes, MCU and connector positions. variant.py holds the per-board numbers for the 60-key main board and a 2x4 test board.
  2. gen_schem.py writes the power/USB and MCU sheets, gen_analog.py the mux and sensor sheets.
  3. kicad-cli sch export netlist produces the netlist; pcb_from_netlist.py creates footprints, fields and pad nets from it; place.py positions every part from geom.
  4. route.py exports Specctra DSN, runs Freerouting headless, imports the session, fans out the plane nets, writes zones as file text and lets kicad-cli pcb drc --refill-zones --save-board fill them.
  5. The plate, the case and the firmware’s mux matrix are generated from the same sources.
You want toDo thisNot this
Move a partChange geom.py or the placer, re-run make pcb (it strips the routing: reroute and re-run the gates)Nudge it in the GUI: the next placement run recomputes every position
Change the circuitEdit the generator, run make schem analog, then make reproEdit a .kicad_sch by hand: make repro fails on the diff
Encode an electrical intent DRC does not check by defaultA rule in the board’s .kicad_dru, before routingA note for the reviewer
Accept a known violation on a board already orderedA baseline entry with its exact message, items and countA GUI exclusion: the gate fails on any exclusion
Know a new gate worksA fault test that injects the fault into a copy and asserts the gate names itA green run on the real board
Let an agent edit the boardA coding agent with kicad-cli, editing files and running the gatesA plugin whose tools replace direct file edits (one such plugin lost its author’s own A/B test, see lessons)

A hand-drawn KiCad project is a set of files nobody can diff meaningfully and no CI job can rebuild. Generating it gives three properties, each checked by a gate:

  • One number lives in one place. The PCB placer, the plate generator and the case CAD import geom.py, so a key pitch or a mount hole cannot disagree between the board and the case. The placer recomputes every position on each run; parts it does not recognise keep their position and are printed as unplaced, never dropped.
  • Output is deterministic. UUIDs are derived (uuid5 over the symbol link or reference) instead of random, so regenerating an unchanged sheet is a no-op diff, and make pcb gives a byte-identical file from fresh and on re-run. make repro regenerates the schematic, analog sheets, plate and keyboard.json for both board variants and fails if anything under hardware/ or firmware/keyboards/ changed.
  • Nothing needs a GUI. KiCad 10 has no kicad-cli pcb update-from-schematic (its pcb subcommands are drc, export, import, render and upgrade), so pcb_from_netlist.py builds the board with KiCad’s pcbnew Python module. Placement, the routing steps, the verifiers and the JLC export use that module too; netlist export, ERC, DRC, plotting and zone fill are kicad-cli. Both ship in the kicad/kicad:10.0.8 image the CI job runs.

The boundary of “reproducible” is routing. Freerouting’s optimiser is multithreaded, so two runs can differ; the routed board is committed and CI checks it with strict DRC instead of regenerating it. make pcb strips tracks, vias and zones, because placement invalidates routing.

Freerouting reads a Specctra .dsn and writes a .ses session for re-import1; the project runs the 2.5.0 jar headless (--gui.enabled=false) inside an eclipse-temurin:25-jre container, because the jar needs Java 25. Four steps around it are not the autorouter’s:

StepWhat it doesWhy
Pre-route escapes (preroute.py)Fixed stubs out of pads the router cannot leave: the mux end of the select bus at 0.65 mm pitch, one USB-C padThe router treats the stubs as fixed and routes only the far end of each net
Plane-net fan-out (gnd_stitch.py)Every GND and +3V3A pad gets a planned via; those nets never reach the routerThey are poured planes on the 4-layer board, not traces
USB length match (route.py match_usb)Meanders the shorter USB leg after routingFreerouting has no length matching; the pair’s length mismatch ranged from 3.5 to 18 mm. With two reserved keep-out corridors alone, before the meander existed, the test board’s total mismatch was 2.64 mm
Zones as file text(zone ...) s-expressions appended to the board file, filled by kicad-cliA Python-created pcbnew ZONE segfaulted SaveBoard and the zone filler on 10.0.7

The main board is 4 layers: F.Cu signal with a GND fill, In1.Cu a +3V3A plane, In2.Cu a GND plane next to the parts layer, B.Cu parts and routing with a GND fill, plus 60 copper-free windows over the Hall sensors on the top three layers. The 2x4 test board stays 2-layer and doubles as a regression run of the whole pipeline.

GateReadsFails onA real catch
ERC (make erc)kicad-cli sch erc --format json --severity-allAny class outside the allow list, any exclusion, a newly ignored classLibrary-availability classes are the only allowance, because the dev box has no global KiCad libraries
Placement DRC / routed DRCkicad-cli pcb drc --format json --severity-all + gate-baseline.jsonAny violation outside the baseline and a short allow list (missing-library classes; the USB-C silk past the edge in the routed gate; unrouted and cosmetic classes in the placement-stage gate), a stale baseline entry, a missing sentinelThe USB pair rules below
Netlist contract (netcheck.py)The exported netlist and keyboard.jsonAny sensor, mux, rail, USB-C, PHY, crystal, reset/boot or SWD net off contract; a key the firmware would read from the wrong mux channel; a keyboard.json pin name that is not the MCU pin the net reachesA transposed mux matrix, which compiles because the main board’s matrix is square
Placement verify (verify_placement.py)Board and plate against geom.pySwitch, sensor, cap, mux, USB-C, hole or outline off its computed positionPeg clearance over every non-switch pad
Placement audit (place_audit.py)The board only, re-derived from geom and the padsThe same properties, computed without importing the verifierMux orientation read from its own pads
CPL rotation (jlc_rotation_check.py)Board + JLC’s footprint per LCSC part (EasyEDA API)A CPL rotation that does not map the KiCad pads onto JLC’sSOT-23-5/6 parts 90 deg off under a package-name rotation table
BOM (bom_check.py)Netlist + JLC parts APIAn LCSC number whose live part has the wrong value, the wrong package size (passives), stock below 5 per placed part, or a part with no LCSC fieldA capacitor filed as 30 pF that is 330 pF
Regeneration (make repro)git status after regeneratingAny change under hardware/ or firmware/keyboards/Reproducibility held at review time but was not enforced; now CI runs it

tools/report_gate.py reads kicad-cli’s JSON report, prints a count per class and exits 1 on:

  • a class outside --allow, except violations the baseline accepts and the sentinel;
  • a report written without --severity-all (the JSON’s included_severities lacks exclusion), because excluded violations would be invisible;
  • any violation marked excluded, and any DRC or ERC exclusion in the .kicad_pro, matching or not;
  • a check class set to ignore in the .kicad_pro (KiCad lists those as ignored_checks) that the baseline’s frozen list does not name. The frozen list on rev 1 holds 7 DRC and 4 ERC classes, and anything added to it is a reviewed change;
  • a sentinel that did not fire exactly once;
  • an accepted violation that no longer occurs.

An accepted violation matches on type, the full description (KiCad puts the measured value in it) and every item’s description and position to 4 decimal places, and it is consumed up to its count. One more identical hit, a changed value or a moved item is a new violation. A stale entry fails too, because either the violation was fixed or the rule that raised it left the rule file, which the sentinel cannot see.

The .kicad_pro exclusions are why the gate bans them outright. KiCad stores a DRC exclusion as a serialised marker, type|x_nm|y_nm|main_uuid|aux_uuid, and a run without --severity-exclusions (or --severity-all) does not report it2. An exclusion made in the GUI to quieten one marker therefore hides a real violation from every default report.

netcheck.py asserts the circuit against the written interface document: every sensor on the mux channel geom gives it with its two capacitors, every mux’s COM, select, rail and unused channels, the MCU pins that carry the select bus and the eight ADC inputs, then power, USB-C, the HS PHY, crystal, reset/boot and SWD as a pin table. It runs 907 checks on the main board as of 2026-11-05 (894 when the board contract landed). It also emulates the firmware’s lookup: libhmk writes transpose(matrix) to C and indexes it [channel][input], so the file must hold [input][channel]. The main board’s 8x8 matrix was stored the other way round until 2026-11-03; a square matrix compiles either way, which is how it survived until the gate read it the way the firmware does.

verify_placement.py checks the board and plate against geom.py. place_audit.py checks the same board without importing the verifier: it re-derives every expectation from geom and the board itself (pad nets, pad positions, footprint layers), so a bug shared by the placer and its own verifier still shows up. The audit reads the mux orientation from the pads (pin rows along y, COM on the +x row, select pins on the -x row) instead of trusting the footprint’s rotation field, and it bounds the USB-C front copper to 0-0.5 mm inside the board edge and the connector’s overhang past the edge to 2.1 mm.

CPL rotation against JLC’s own footprints

Section titled “CPL rotation against JLC’s own footprints”

The CPL rotation correction depends on how JLC’s library footprint for that exact LCSC part is drawn, not on the KiCad package name. On 2026-11-04 a single “SOT-23 -> 180” rule was right for SOT-23-3 and 90 deg wrong for SOT-23-5/6, and JLC’s placement preview showed the two SOT-23-5/6 parts rotated. The check fetches JLC’s footprint per part, finds the rotation that maps the KiCad pads onto it, and checks the exporter’s correction.

It then checks the final CPL angle against geometry computed separately: the part’s pads as they sit on the board, seen from below for a bottom-side part (the board turned over about its Y axis, so x -> -x). Every JLC-placed part on this board is on B.Cu, so the mirror term dominates. The exporter’s bottom formula is 180 - angle; a refactor to angle + 180 agrees at 0 and 180 deg and rotates the 90/270 deg parts, including all 60 sensors, by 180; run against that mutant, the check flagged 65 placements. The review found the first version checked only the correction table; the mirror check was added the same day.

bom_check.py resolves every LCSC field against JLC’s parts API for value (digit-bounded, so 30 pF does not match 330 pF), package size for passives, and stock of at least 5 per placed part; a part with no LCSC field fails too, except switches, mounting holes and test pads. It caught C1664, entered as 30 pF and actually 330 pF, on 2026-11-03. Stock perishes, so make order runs it again with the rotation check and routed DRC before writing the upload pack.

KiCad stores custom design rules in a .kicad_dru file beside the .kicad_pcb and .kicad_pro3, and kicad-cli pcb drc loads it too (measured here on 10.0.8). The manual says the file is managed by the Board Setup dialog and should not be edited externally; this project generates and commits it as text instead. The constraints that matter for a USB 2.0 High-Speed pair are diff_pair_gap (min/opt/max on parallel portions), diff_pair_uncoupled (max length routed apart, including fanouts) and skew with within_diff_pairs (length difference tested per pair)3. The board’s rules:

(rule "USB HS pair coupled"
(condition "A.inDiffPair('USB_D*')")
(constraint diff_pair_gap (min 0.1mm) (opt 0.15mm) (max 0.25mm))
(constraint diff_pair_uncoupled (max 5mm))
)
(rule "USB HS pair skew"
(condition "A.inDiffPair('USB_D*')")
(constraint skew (max 1.0mm) (within_diff_pairs))
)

Rev 1 was routed by Freerouting, which knows nothing about differential pairs. The review judge sampled USB_D+ every 0.1 mm on the U1->U4 leg: 9.8 mm of it at 5 mm or more from USB_D-, 1.2 mm within 0.5 mm. The rules turn that into DRC output: gap violations at 5.1181, 4.1181 and 0.2626 mm, each reported twice (six in total), 31.99 mm uncoupled against 5 mm, and 2.89 mm skew against 1.0 mm. Those exact violations are in the baseline because the board was already ordered; any other hit fails, and the baseline entry is deleted when rev 2 routes the pair coupled. The rules were written only after a reviewer found the problem; written before routing, they would have failed rev 1’s DRC.

kicad-druid’s README describes the same failure (“no message, empty stderr, exit code 0”) and says an unknown property or a unit-less literal drops only that rule and everything after it, so its sentinel goes last4. On 10.0.8 the project did not see partial dropping: a sentinel placed before the bad rule was lost too. The README also says lowercase 'pad' literals never match; on 10.0.8 A.Type == 'pad' matched. Treat both as version-specific.

The board’s rule file ends with a rule that fires on every run that loaded the file:

(rule "GATE SENTINEL"
(condition "A.Type == 'Pad' && A.memberOfFootprint('MH1')")
(constraint hole_size (min 100mm))
)

It asks the NPTH pad of mounting hole MH1 (2.2 mm drill) for a hole of at least 100 mm, so it always produces one drill_out_of_range and never a real finding. The baseline declares it, and report_gate.py requires exactly one violation of that type naming rule GATE SENTINEL: zero means the file was dropped, two means a copy. An excluded sentinel still counts as one, and fails through the exclusion checks instead. It sits last because KiCad evaluates rules in reverse order and stops at the first match3, so a later hole_size rule would override it on MH1.

The sentinel shows the file loaded; whether the USB rules are still in it is checked by the baseline. Deleting the skew rule leaves the sentinel firing; the stale baseline entry for the skew violation is what fails then.

The JLCPCB section of the rule file is copied from kicad-druid v1.2.0 (MIT, pinned to a commit, licence text kept in the file)4: drill and via sizes, annular rings, SMD pad size, through-hole-only vias, slot widths, hole and copper clearances, trace width and spacing, and legend size. One rule was removed: “Pad to Silkscreen” (0.15 mm) hit 199 pads on rev 1, all stock KiCad passive footprints with silk 0.10 mm from the pad. Two kinds of its hits are baselined for rev 2: a GND via 0.397 mm from a switch’s NPTH peg against druid’s 0.45 mm, which the project treats as druid’s inference (JLC publishes via hole-to-hole 0.2 mm and pad hole-to-hole 0.45 mm, and nothing for via to NPTH5); and 13 test-pad labels at 0.8 mm height and 0.12 mm stroke against JLC’s 1.0 mm and 0.15 mm legend minimum5.

FlagDoesUsed for
--format jsonMachine-readable report, with ignored_checks, included_severities and an excluded flag per violationEvery gate; no regex over the text report
--severity-allReports errors, warnings and exclusions2Required by the gate, so exclusions are visible
--exit-code-violationsExit 5 if any violation exists, 0 otherwise2The strict make drc; only its plate half can pass, because on the main board the sentinel and rev 1’s baselined violations make it exit 5 by design, so the board’s gate is drc-routed
--schematic-parityTests the PCB against the schematic2Run by hand: on this board it reports metadata-only mismatches (Datasheet, Description, exclude-from-BOM fields the importer does not copy), no net, footprint or value mismatches, so it is not in the gate yet
--refill-zones --save-boardFills zones and writes the boardThe last step of routing; never on a check

A gate that was never seen failing is unverified. The suite in tools/faults/ copies one real input (netlist, keyboard.json, board) to a temp directory, injects one fault, runs the real gate on the copy and asserts exit 1 plus a message that names the fault. Every gate also has a control: the unmodified copy passes, so a red fault test cannot come from a broken copy. The DRC faults run the real make drc-routed target pointed at the copy, so the allow list stays in one place.

Fault injectedGate that must name it
Switch shifted 0.2 mm, sensor 0.5 mm off its switchverify, audit
Mux rotated 90 degverify, audit, routed DRC (shorting_items)
Sensor capacitor moved onto a switch pegverify, audit, routed DRC (hole_clearance)
USB-C 1 mm past the top edgeverify, audit
USB-C recessed 3 mmverify, audit, routed DRC (unconnected_items)
USB_D- track segment deleted; one GND fan-out via deletedrouted DRC
USB pair gap widened 0.1 mmrouted DRC: a new diff_pair_gap_out_of_range plus a stale baseline entry
Skew rule deleted from the rule filerouted DRC: stale baseline entry for the skew violation
Rule file with a bad layer name, or no rule filerouted DRC: sentinel fired 0 times
Sentinel excluded in the .kicad_pro; a class newly set to ignorerouted DRC
Transposed matrix, wrong ADC or select pin name in keyboard.jsonnetcheck, gen_keyboard_json
Wrong LCSC value, missing LCSC fieldBOM check

Two details from writing the faults. A deleted GND via is not a fault on most pads, because the B.Cu pour also reaches them and KiCad correctly reports them connected; the test picks a capacitor pad outside that pour, whose via is its only ground. And a fault test is only proof once the gate it targets has been neutered in a copy and the test went red: the project did that for the verifier, netcheck and report_gate.py, and again for each check added in the gap fixes.

Faults a gate did not catch went in as strict-xfail rows marked gap, each a recorded gate gap. When a gate learns to catch the fault the row turns XPASS, which fails the run, so the mark cannot outlive the gap. The first suite (2026-11-05) collected 88 tests: 53 fault and control tests and 25 geometry/variant invariants passing, and 10 gap rows: mux rotation, a capacitor on a peg in the verifier, USB-C inset bounds, and keyboard.json pin names that netcheck did not read. The fixes gave the verifier peg clearance over every non-switch pad, a mux rotation check and a USB-C recess bound; gave the audit the mux orientation from its pads, the 0-0.5 mm bound on the USB-C front copper and the 2.1 mm overhang cap; and gave netcheck the pin-name check. The suite then ran 88 passed, 0 xfailed, and 107 passed once the DRC-gate rows (sentinel, baseline, exclusion, ignored class) were added; the BOM rows need the live JLC API and skip without it.

With the gates green (make check exit 0, verify and audit 444/444; at 2026-11-05 HEAD verify runs 416 board and 15 plate checks and the audit 452), five reviewers worked the repo in parallel: electrical, PCB/fab, firmware, mechanical, tooling/docs. A judge then spot-checked the load-bearing findings against the files. Of 17 findings, 10 were confirmed, 2 rejected, 1 partly rejected, 3 accepted as known or rev 2 items and 1 downgraded.

FindingVerdictWhat the judge checked
USB pair routed as two traces, not a coupled pairConfirmed, severity changedSampled the trace; the reviewer called it Full-Speed and tolerable, but the interface is High-Speed
CNC case corners collide with the board cornersConfirmed (CNC variant only)Arithmetic: the R3.0 cavity fillet overlaps a sharp board corner by 0.54 mm
Case check passes the CNC case anywayConfirmedThe reviewer’s boolean intersection: 1.96 mm3 of case inside the PCB with every check passing
CI ran only five targets, no tests, verify, audit, routed DRC or regeneration diffConfirmedThe workflow file
Rotation check never tests the bottom-side mirrorConfirmedThe check’s source
Mux matrix scrambles about 48 keysRejectedThe matrix equals the one rebuilt from the mux map; the firmware transposes it on build
“Zero silkscreen text”Partly rejected13 test-pad labels exist; name, revision, date and licence are missing
Sensor windows cut the inner GND planeRejected as a defectDeliberate, surveyed before layout; USB does not cross a window

The rejected matrix finding shows a doc claim read as evidence about code. The firmware reviewer read a note in the repo’s agent instructions describing the matrix bug as still open and repeated it as a blocker without checking the file against the mux map. A doc claim was treated as evidence about the code, and the doc was stale. Two other reviewer errors were of the same kind: treating the USB interface as Full-Speed, when High-Speed is why the board uses its MCU, and arguing that sequential sampling lowers the regulator load, when every sensor is powered all the time. Most confirmed rows were fixed the same day, the stale doc line among them; the USB pair became the failing rule above.

The review is also why the gates grew. CI now runs verify, audit, routed DRC, the regeneration diff and the test suite; the USB finding became a rule file; the case check gained a boolean collision against the real board file.

  • Write the rule file before routing. Two rules with three constraints describe the USB pair’s electrical intent, and the autorouter that ignored it would have failed DRC on its first run.
  • Keep an always-firing sentinel in any .kicad_dru and fail when it is missing. On 10.0.8 a typo disables every custom rule with no output.
  • Use the JSON report with --severity-all, freeze the ignored classes, and ban exclusions. A baseline entry carries its measured value and an owner, and fails when the violation changes or goes away.
  • Give every gate a fault test, and run it once with the gate neutered. The suite found 10 gaps in gates that had been passing on the real board.
  • Keep the audit independent of the verifier. Here both read geom, but the audit imports none of the verifier’s code, so a bug in a verifier helper cannot pass both.
  • pcbnew’s SWIG bindings are removed in KiCad 11. The SWIG pcbnew module still exists in KiCad 9 and 10 and is removed in KiCad 11, which adds kicad-cli api-server for headless use of the IPC API6. The project writes zones and the post-route repair as file text and fills zones with kicad-cli, because the SWIG layer failed in four ways: on 10.0.7 a Python-created ZONE segfaults SaveBoard and the zone filler, some field and outline calls return an untyped SwigPyObject, and repeated LoadBoard/SaveBoard in one interpreter corrupts the bindings until the next call fails (so best-of-N routing runs in separate processes); board.Remove() on a via also segfaults (recorded without a version). The rest of the tooling still uses the SWIG module, and that is the part a move to KiCad 11 would touch.
  • A KiCad MCP server was not needed here. The author of a 109-tool KiCad MCP plugin ran it against a plain coding agent with KiCad installed, same model and tasks, judged by KiCad itself: six single edits three times each, 18 of 18 right without the plugin and 3 of 18 with it. In a 20-edit session both got every edit right and the plugin used about 1.8 times the context. The plugin’s failures were missing tools, an instruction never to edit files directly, and a board-sync tool that rewrote almost every footprint. The author calls these small tests on one design, not a verdict on every board or model, and did not publish the test material; they still put the plugin in maintenance mode and recommend an agent that checks its changes with kicad-cli7. That is the setup this board was built with, with the conventions kept in a skill file as described in the skills-system page.
  • Check a doc claim against the file it describes before repeating it. The rejected review blocker came from a stale line in the repo’s own agent instructions.
Is the change a number(position, size, pitch)?Change geom.py,regenerate, make reproyesIs it an electrical intentDRC does not check?noRule in .kicad_dru+ sentinel stays lastyesIs it a violation on aboard already ordered?noBaseline entry:exact message, items, countyesIs it a new gate?noFault test + control+ neuter the gate onceyesFix it in the generatorno

The same guide as a list:

  1. A number (a position, a size, a pitch): change geom.py, regenerate, and let make repro confirm the committed files match.
  2. An electrical intent the default DRC does not check: a rule in the board’s .kicad_dru, with the sentinel kept last.
  3. A violation on a board that is already ordered: a baseline entry with its exact message, items and count, owned by the work item that fixes it.
  4. A new gate: a fault test, a control, and one run with the gate neutered to see the test go red.
  5. Anything else: fix it in the generator, never in the generated file.

All “project result” rows were read from the project’s repo (private) on 2026-11-05.

ClaimHow it was checkedStatus
Generators reproduce committed files byte for byte; routing excludedThe project’s Makefile: the repro target and its comment on the regenerated pathsproject result, enforced in CI
KiCad 10 has no pcb update-from-schematickicad-cli pcb --help on 10.0.8; the netlist importer’s docstringproject result
Freerouting 2.5.0 headless, Java 25The project’s routing script (jar URL, Java 25 note, --gui.enabled=false); Freerouting CLI documentation for the flag and file types1project result + documented
Test board USB mismatch 2.64 mm with the keep-out corridors, before the meanderThe project’s agent notes, routing workflow; the routing script at the commit that reported 2.64 mm has no meanderproject result
USB rules produce 3 gap values x2, 31.99 mm uncoupled, 2.89 mm skew on rev 1The board’s DRC baseline file, first accepted groupmeasured on rev 1
Judge sample: 9.8 mm at 5 mm or more apart, 1.2 mm within 0.5 mmThe project’s review report, row 1measured by the judge
Bad .kicad_dru dropped whole with no message on 10.0.8The project’s KiCad CI verification notes, section 4.1 (11 cases)measured; kicad-druid README agrees on silence, disagrees on partial dropping4
Rules evaluated last-first, first match winsKiCad PCB editor manual, custom rules3documented
Exit 5 on violations; --severity-all includes exclusionsKiCad CLI manual2documented
Sentinel must fire exactly once; any exclusion fails; frozen ignored classes (7 DRC, 4 ERC)The report gate’s docstring and checks; the board’s DRC baseline fileproject result, fault-tested
kicad-druid v1.2.0 JLC rules, Pad to Silkscreen removed (199 pads)Header of the board’s .kicad_druproject result
JLC via 0.2 mm / pad 0.45 mm hole spacing; legend 1.0 mm / 0.15 mmJLC capabilities page, re-read 2026-11-055documented
netcheck 907 checks on main (894 when the board contract landed)make netcheck on 2026-11-05 HEAD; the review report’s status note and the board-contract commit message for 894project result
SOT-23-5/6 90 deg off under a package rule; an angle + 180 mutant flagged 65 placements, all 60 sensors among themThe rotation check’s docstring; the commit message that added the mirror checkproject result
C1664 filed as 30 pF, is 330 pFThe BOM check’s docstringproject result
88 collected at first (53 fault/control + 25 geometry/variant passing, 10 strict xfail), then 88 passed 0 xfailed, now 107 passedpytest --collect-only on the first suite’s commit (63 fault + 25 geometry tests); commit messages of the suite and the gap fixes; make faults on 2026-11-05 HEADproject result
17 review findings: 10 confirmed, 2 rejected, 1 partly rejectedThe project’s review report, verdict tableproject result
SWIG pcbnew removed in KiCad 11kicad-python README6documented, not tested here
pcbnew failures on 10.0.7The project’s agent notes, routing workflow (via-removal crash recorded without a version)observed by the project
MCP A/B: 18/18 vs 3/18, about 1.8x contextmcp-server-kicad README, “Why maintenance mode”7reported by that author, not re-run
  1. Freerouting, “Command Line Interface (CLI) Documentation,” GitHub. https://github.com/freerouting/freerouting/blob/HEAD/docs/command_line_arguments.md ↩ ↩2

  2. KiCad, “Command-Line Interface: pcb drc,” KiCad 10.0 Documentation. https://docs.kicad.org/10.0/en/cli/cli.html ↩ ↩2 ↩3 ↩4 ↩5

  3. KiCad, “PCB Editor: custom design rules,” KiCad 10.0 Documentation. https://docs.kicad.org/10.0/en/pcbnew/pcbnew.html ↩ ↩2 ↩3 ↩4

  4. Cimos, “kicad-druid: README, Validation,” GitHub. https://github.com/Cimos/kicad-druid ↩ ↩2 ↩3

  5. JLCPCB, “PCB Manufacturing and Assembly Capabilities,” JLCPCB. https://jlcpcb.com/capabilities/pcb-capabilities ↩ ↩2 ↩3

  6. KiCad, “kicad-python README,” GitLab. https://gitlab.com/kicad/code/kicad-python ↩ ↩2

  7. ProductOfAmerica, “mcp-server-kicad: Why maintenance mode,” GitHub. https://github.com/ProductOfAmerica/mcp-server-kicad#why-maintenance-mode ↩ ↩2