← All Pull Requests

Refill routing snapshots and guard incomplete DRC reports #3

Closed opened by John Lauer 2026-09-07
Merges fix/live-routing-drc-completeness → master

Routing snapshots can retain stale zone fills, and capped KiCad DRC lists can hide new errors when comparing before and after. Refill the disposable snapshot, mark capped unconnected counts as lower bounds, and reject error-delta validation when either violation report may be truncated. The live board remains unchanged on rejection.

This ports the missing local zone-refill and DRC-completeness changes onto master 96b31b3 (insiders 0.9.340), preserving its ABI-specific vendored dependency loading. It also restores the unique offline test fixture name so a real open demo cannot trigger the unrelated open-board guard.

Validation: released master passes 27 unit tests; this candidate passes 30, including snapshot refill with project/custom rules, source preservation, cap boundaries, and rejection before mutation. Multi-host release testing is underway; this follow-up is not yet installed or tested live. No release version bump is included.

Diff Skip to comments (2)

handlers/live_routing.py+20−2
@@ -174,6 +174,18 @@ def _plan(data, args):     return {"netName": netname, "segments": segments, "vias": vias, "blocks": blocks}  +def _report_limits(violations, unconnected):+    # KiCad 10.0.3 DRC_ENGINE: 199 per type, extended to 499 for+    # clearance and unconnected items. At the boundary completeness is unknown.+    counts = Counter(v.get("type") for v in violations)+    capped = sorted(str(kind) for kind, count in counts.items()+                    if count >= (499 if kind == "clearance" else 199))+    return {"unconnectedReportMayBeTruncated": len(unconnected) >= 499,+            "unconnectedCountIsLowerBound": len(unconnected) >= 499,+            "violationReportMayBeTruncated": bool(capped),+            "possiblyTruncatedViolationTypes": capped}++ def _drc(info, args, text):     exe = info.get("kicad_cli_exe")     if not exe or not Path(exe).is_file():@@ -191,8 +203,10 @@ def _drc(info, args, text):                 shutil.copy2(sibling, candidate.with_suffix(suffix))                 copied.append(str(sibling))         report = Path(tmp) / "drc.json"+        # Copper edits invalidate cached zone fills. Refill the disposable+        # snapshot so connectivity reflects the proposed copper, not old pours.         proc = subprocess.run([exe, "pcb", "drc", "--format", "json", "--severity-all",-                               "--all-track-errors", "--output", str(report), str(candidate)],+                               "--all-track-errors", "--refill-zones", "--output", str(report), str(candidate)],                               capture_output=True, text=True, timeout=90)         if proc.returncode or not report.is_file():             raise RoutingError("drc_failed", proc.stderr or proc.stdout or "No DRC report")@@ -204,7 +218,8 @@ def _drc(info, args, text):             "unconnected": len(unconnected), "violations": violations,             "unconnectedItems": unconnected,             "clean": not violations and not unconnected,-            "source": "live-editor-snapshot", "projectRulesCopied": copied}+            "source": "live-editor-snapshot", "projectRulesCopied": copied,+            "zonesRefilled": True, **_report_limits(violations, unconnected)}   def _fingerprint(v):@@ -213,6 +228,9 @@ def _fingerprint(v):   def _new_errors(before, after):+    if any(r.get("violationReportMayBeTruncated") for r in (before, after)):+        raise RoutingError("drc_incomplete", "DRC violation lists may be capped; cannot certify the error delta. No live copper was changed",+                           mutated=False)     existing = Counter(_fingerprint(v) for v in before["violations"] if v.get("severity") == "error")     added = []     for v in after["violations"]:
tests/test_live_routing.py+46
@@ -19,6 +19,52 @@ def board():              'pads':[{'number':'1','x':0,'y':0,'net_name':'GND','layers':['B.Cu'],'type':'smd'}]}]}  +class DrcSnapshot(unittest.TestCase):+    def test_capped_opens_are_lower_bound_without_blocking_error_comparison(self):+        limits = live._report_limits([], [{}] * 499)+        self.assertTrue(limits['unconnectedCountIsLowerBound'])+        self.assertFalse(limits['violationReportMayBeTruncated'])+        self.assertEqual(live._new_errors({'violations': [], **limits}, {'violations': [], **limits}), [])+        self.assertFalse(live._report_limits([], [{}] * 498)['unconnectedCountIsLowerBound'])++    def test_per_type_caps_block_error_delta_even_when_reports_match(self):+        for kind, cap in [('clearance', 499), ('shorting_items', 199)]:+            v = {'type': kind, 'severity': 'error', 'items': []}+            self.assertFalse(live._report_limits([v] * (cap - 1), [])['violationReportMayBeTruncated'])+            report = {'violations': [v] * cap, **live._report_limits([v] * cap, [])}+            with self.assertRaises(live.RoutingError) as cm:+                live._new_errors(report, report)+            self.assertEqual(cm.exception.code, 'drc_incomplete')+            self.assertFalse(cm.exception.detail['mutated'])++    def test_refills_snapshot_with_project_rules_without_changing_source(self):+        import json+        from types import SimpleNamespace+        with tempfile.TemporaryDirectory() as tmp:+            source = Path(tmp) / 'board.kicad_pcb'+            source.write_text('original board')+            source.with_suffix('.kicad_pro').write_text('project rules')+            source.with_suffix('.kicad_dru').write_text('custom rules')+            exe = Path(tmp) / 'kicad-cli'+            exe.touch()+            def run(command, **kwargs):+                candidate = Path(command[-1])+                self.assertNotEqual(candidate, source)+                self.assertEqual(candidate.read_text(), 'live snapshot')+                self.assertEqual(candidate.with_suffix('.kicad_pro').read_text(), 'project rules')+                self.assertEqual(candidate.with_suffix('.kicad_dru').read_text(), 'custom rules')+                self.assertIn('--refill-zones', command)+                Path(command[command.index('--output') + 1]).write_text(json.dumps({+                    'violations': [], 'unconnected_items': [{'items': []}]}))+                return SimpleNamespace(returncode=0, stderr='', stdout='')+            with patch.object(live.subprocess, 'run', side_effect=run):+                result = live._drc({'kicad_cli_exe': str(exe)}, {'filePath': str(source)}, 'live snapshot')+            self.assertTrue(result['zonesRefilled'])+            self.assertEqual(result['unconnected'], 1)+            self.assertFalse(result['clean'])+            self.assertEqual(source.read_text(), 'original board')++ class Planning(unittest.TestCase):     def test_rotated_pad_coordinates_and_final_layer_transition(self):         p=live._plan(board(), {'net':'GND','points':['J1.1',{'pad':'J2.1','layer':'B.Cu'}]})
tests/test_routing_ipc.py+7−1
@@ -140,7 +140,13 @@ class OfflineTests(unittest.TestCase):      def test_atomic_file_edit_and_open_board_refusal(self):         with tempfile.TemporaryDirectory() as d:-            path = make(d); before = path.read_bytes()+            # A user can have the demonstration's default "live-routing" board+            # open on the Windows test host. Give this disposable fixture a+            # unique title so the real open-editor guard does not match it.+            fixture = make(d)+            path = fixture.with_name(Path(d).name + '-offline-test.kicad_pcb')+            fixture.rename(path)+            before = path.read_bytes()             r = route.handle_route({}, {'filePath':str(path),'net':'NET_1','points':['J1.1','J2.1']})             self.assertTrue(r['success'], r)             self.assertEqual(Path(str(path)+'.adom-bak').read_bytes(), before)

Comments

John Lauer · 2mo ago

Added released-runtime acceptance evidence at demo/routing/evidence/winvm-2026-09-07.json: KiCad 10.0.6, AB 2.1.68, insiders 0.9.340. One-net live commit, dry run, revision/net guards, snapshot DRC, native Undo/Redo and removal passed. This tests the shipped release, not the proposed capped-report/zone-refill changes.

Also corrected the routing README setup sequence to close KiCad before enabling IPC and documented snapshot validation limits. Local proposed code still passes 30 tests.

Broader release audit findings are filed as issues #78 (fixture/coverage), #79 (ZIP source differs from master), #80 (stock library path resolution on all three hosts), and #81 (winvm GUI failures). No GChat announcement was sent. AdomLapper remains pinned pending the confirmation requested in the maintainer handoff.

John Lauer · 2mo ago

Integrated into master and shipped in insiders 0.9.341 (zip sha256 33b0986451ed636b426784abb79a0da69b8ed62c0195bf465774aa06260c7bc3). Applied as a patch on top of master rather than merged through the PR, same as PR 79. Container tests: 35 pass (your 33 plus two for the readiness refresh). Live on arav-rog (KiCad 10.0.3, Python 3.12): copper cleared, kicad_routing_validate reports violationReportMayBeTruncated:false and the new fields, then the per-net demo ran through the refilled DRC preflight and finished with 0 errors, 0 unconnected, 3 warnings. Thank you for the port; the unique fixture name fix is in as well.

Log in to comment.