From 474fcdd9b3f4d486380d1c94156e7bd08e7c6ede Mon Sep 17 00:00:00 2001 From: subzero Date: Wed, 20 May 2026 19:55:12 +0200 Subject: [PATCH 1/2] docs: cumulative-rules narrative (Phase 2b.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `docs/the-rules.md` — a single page indexing every user-facing rule the framework enforces. Per entry: the rule, the physical justification, the exception class that fires when violated (link to `framework/errors.py`), and the demo where users first see the rule caught. Twelve rules in five-minute reading length. Reviewer feedback addressed: "the rules feel arbitrary if you encounter them one at a time without the cumulative narrative; they feel principled if you can see the whole set on one page with the physical justification for each." The rules doc sits between `design-principles.md` (the abstract) and `learning-path.md` (the graduated exposure) in the site navigation. Cross-references: - `learning-path.md` gains a *First catches* column on the demo table naming the rule numbers each demo first surfaces; a closing paragraph notes that framework-internal refusals (Rules 8, 10, 11, 12) don't anchor to a single demo. - `the-rules.md` links each rule to its first-caught demo. `tests/docs/test_rules_doc.py` keeps the doc in sync as the exception hierarchy evolves: every curated user-facing rule exception is named in the doc, every `XxxError` reference in the doc resolves to a real class (catches rename typos), every `../demos//` link resolves to an actual directory, and the mkdocs nav orders design-principles → the-rules → learning-path. Suite: 4795 passed (33 new), mypy clean. --- docs/learning-path.md | 48 ++++++----- docs/the-rules.md | 122 ++++++++++++++++++++++++++ mkdocs.yml | 3 +- tests/docs/__init__.py | 0 tests/docs/test_rules_doc.py | 161 +++++++++++++++++++++++++++++++++++ 5 files changed, 312 insertions(+), 22 deletions(-) create mode 100644 docs/the-rules.md create mode 100644 tests/docs/__init__.py create mode 100644 tests/docs/test_rules_doc.py diff --git a/docs/learning-path.md b/docs/learning-path.md index a66b48d..bc91969 100644 --- a/docs/learning-path.md +++ b/docs/learning-path.md @@ -16,32 +16,38 @@ If you've never lit an LED with a battery, start with [the project's Hello World ## The demos in order -| # | Demo | What it teaches | -|----|-------------------------------|---------------------------------------------------------------------------------------| -| 1 | [`hello_led/`](../demos/hello_led/) | The minimal viable circuit — one LED, one resistor, two rails. Reading point.| -| 2 | [`penfold_light_switch/`](../demos/penfold_light_switch/)| First sensor circuit. LDR + comparator + transistor switch. Penfold BP107 P3.| -| 3 | [`water_alarm/`](../demos/water_alarm/) | Composing chips, wiring rails, latching logic. Four chips, two LEDs. | -| 4 | [`penfold_reaction_game/`](../demos/penfold_reaction_game/)| Sequential digital — counter ring + button-stops-clock topology. Penfold P22.| -| 5 | [`dice/`](../demos/dice/) | Classic 555 + 4017 + diode-OR matrix. Recognisable hobbyist staple. | -| 6 | [`digital_thermometer/`](../demos/digital_thermometer/) | First MCU project. ATmega328P + DHT11 + 7-seg display. Firmware-as-cell. | -| 7 | [`penfold_one_second_timer/`](../demos/penfold_one_second_timer/)| Op-amp relaxation oscillator with hysteresis. Penfold BP107 P8.| -| 8 | [`penfold_metronome/`](../demos/penfold_metronome/) | NE555 astable + speaker — the other classical astable. Penfold BP107 P9. | -| 9 | [`penfold_warbling_doorbuzzer/`](../demos/penfold_warbling_doorbuzzer/)| Oscillator composition — slow gates fast. Penfold BP107 P16. | -| 10 | [`doorbell_protector/`](../demos/doorbell_protector/) | Two-555 monostable with transistor switching and a relay. | -| 11 | [`fan_cooling/`](../demos/fan_cooling/) | First `Board` demo. TMP302 + MOSFET-switched fan. Connectors that mate. | -| 12 | [`backup_power/`](../demos/backup_power/) | TI Designs TIDA-03031. Three-stage power architecture (eFuse + boost + buck). | -| 13 | [`water_alarm_split/`](../demos/water_alarm_split/) | Same circuit as #3 but split across two boards via `mate()`. HAT pattern. | -| 14 | [`bldc_motor/`](../demos/bldc_motor/) | ATmega328P + DRV8313 + Hall sensors. Three-phase commutation. | -| 15 | [`isolated_rs232/`](../demos/isolated_rs232/) | TIDA-01230. Cross-domain isolation — first demo to exercise `GroundDomain`. | -| 16 | [`li_ion_fuel_gauge/`](../demos/li_ion_fuel_gauge/) | TIDA-00594. BQ27546-G1 fuel gauge with sense resistor + thermistor. | -| 17 | [`penfold_fuzz_unit/`](../demos/penfold_fuzz_unit/) | Audio domain — op-amp + clipping diodes. Guitar fuzz pedal. Penfold P30. | -| 18 | [`penfold_crystal_set/`](../demos/penfold_crystal_set/) | Passive-only RF — no Rail, no battery. Boundary case. Penfold BP107 P27. | +The *first catches* column names rules from [`the-rules.md`](the-rules.md) that this demo is the first place to surface — i.e. work the demos top-to-bottom and you'll see the framework refuse each rule in a real circuit by the time you reach the demo it's listed against. + +| # | Demo | What it teaches | First catches | +|----|------------------------------------------------------------------------|------------------------------------------------------------------------------|----------------------------| +| 1 | [`hello_led/`](../demos/hello_led/) | The minimal viable circuit — one LED, one resistor, two rails. Reading point.| [Rules 1, 2](the-rules.md) | +| 2 | [`penfold_light_switch/`](../demos/penfold_light_switch/) | First sensor circuit. LDR + comparator + transistor switch. Penfold BP107 P3.| — | +| 3 | [`water_alarm/`](../demos/water_alarm/) | Composing chips, wiring rails, latching logic. Four chips, two LEDs. | [Rule 9](the-rules.md) | +| 4 | [`penfold_reaction_game/`](../demos/penfold_reaction_game/) | Sequential digital — counter ring + button-stops-clock topology. Penfold P22.| — | +| 5 | [`dice/`](../demos/dice/) | Classic 555 + 4017 + diode-OR matrix. Recognisable hobbyist staple. | — | +| 6 | [`digital_thermometer/`](../demos/digital_thermometer/) | First MCU project. ATmega328P + DHT11 + 7-seg display. Firmware-as-cell. | [Rule 3](the-rules.md) | +| 7 | [`penfold_one_second_timer/`](../demos/penfold_one_second_timer/) | Op-amp relaxation oscillator with hysteresis. Penfold BP107 P8. | — | +| 8 | [`penfold_metronome/`](../demos/penfold_metronome/) | NE555 astable + speaker — the other classical astable. Penfold BP107 P9. | — | +| 9 | [`penfold_warbling_doorbuzzer/`](../demos/penfold_warbling_doorbuzzer/)| Oscillator composition — slow gates fast. Penfold BP107 P16. | — | +| 10 | [`doorbell_protector/`](../demos/doorbell_protector/) | Two-555 monostable with transistor switching and a relay. | — | +| 11 | [`fan_cooling/`](../demos/fan_cooling/) | First `Board` demo. TMP302 + MOSFET-switched fan. Connectors that mate. | [Rules 6, 7](the-rules.md) | +| 12 | [`backup_power/`](../demos/backup_power/) | TI Designs TIDA-03031. Three-stage power architecture (eFuse + boost + buck).| — | +| 13 | [`water_alarm_split/`](../demos/water_alarm_split/) | Same circuit as #3 but split across two boards via `mate()`. HAT pattern. | — | +| 14 | [`bldc_motor/`](../demos/bldc_motor/) | ATmega328P + DRV8313 + Hall sensors. Three-phase commutation. | — | +| 15 | [`isolated_rs232/`](../demos/isolated_rs232/) | TIDA-01230. Cross-domain isolation — first demo to exercise `GroundDomain`. | [Rule 5](the-rules.md) | +| 16 | [`li_ion_fuel_gauge/`](../demos/li_ion_fuel_gauge/) | TIDA-00594. BQ27546-G1 fuel gauge with sense resistor + thermistor. | [Rule 4](the-rules.md) | +| 17 | [`penfold_fuzz_unit/`](../demos/penfold_fuzz_unit/) | Audio domain — op-amp + clipping diodes. Guitar fuzz pedal. Penfold P30. | — | +| 18 | [`penfold_crystal_set/`](../demos/penfold_crystal_set/) | Passive-only RF — no Rail, no battery. Boundary case. Penfold BP107 P27. | — | + +Rule 8 (refdes uniqueness) fires the first time you accidentally reuse `refdes_number=` in your own design — it's a construction-time check on every demo but doesn't appear as a near-miss snippet in any. Rules 10, 11, and 12 are framework-internal refusals that surface during refactors rather than in a specific demo. The full table of all twelve rules is on [`the-rules.md`](the-rules.md). + +Cross-referencing the other way: [`the-rules.md`](the-rules.md) lists, for each rule, the demo where you'll first see it caught — so you can read either page first and find your way back to the other. ## What each demo gives you Every demo folder has the same shape: -``` +```text demos// .py # the source — read this first docs/ diff --git a/docs/the-rules.md b/docs/the-rules.md new file mode 100644 index 0000000..28a3954 --- /dev/null +++ b/docs/the-rules.md @@ -0,0 +1,122 @@ +# The rules + +The framework enforces a small, fixed set of construction-time rules. Each one corresponds to a real-world failure mode that costs at the bench — overheated outputs, undefined logic levels, parts that won't seat, boards that don't power up. The rules feel arbitrary if you encounter them one at a time; they're principled if you see the whole set on one page with the physical referent for each. + +This page is the index. For each rule: + +- **The rule itself**, one sentence. +- **Why** — the physical-world referent. +- **What fires** — the exception class wirebench raises ([source](https://github.com/raeq/wirebench/blob/main/src/framework/errors.py)). +- **First caught at** — the demo where you'll meet this rule for the first time if you walk the [learning path](learning-path.md) top to bottom. + +By the time you've worked through `hello_led/` + `water_alarm/` + `5v_rail_power/` + `digital_thermometer/` + `fan_cooling/` + `isolated_rs232/` + `li_ion_fuel_gauge/`, you've seen the framework catch every rule on this page in a real demo. The rules aren't aspirational — they're the load-bearing checks that keep "the code matches the breadboard" honest. + +## Rule 1 — One driver per logical net + +If two ports declared `Direction.OUT` end up on the same logical net, `wire()` (or `Circuit._validate` for nets joined through `mate()`) raises [`ShortCircuitError`](https://github.com/raeq/wirebench/blob/main/src/framework/errors.py). + +**Why:** Two OUT-direction ports on one net fight each other on the copper. Current sinks through the losing output stage until the FETs overheat. Real silicon has one driver per shared conductor. + +**First caught at:** [`hello_led/`](../demos/hello_led/) — the *shorted supply* near-miss snippet. + +## Rule 2 — Every BIDIR-only net needs a driver + +A net touched only by passive BIDIR ports (resistor terminals, capacitor leads, connector contacts) with no OUT-direction driver anywhere raises [`FloatingNetError`](https://github.com/raeq/wirebench/blob/main/src/framework/errors.py). + +**Why:** A net with no driver has no defined voltage. CMOS inputs in particular drift to mid-rail and oscillate, picking up nearby switching noise as if the trace were an antenna. The bench equivalent is *"but the LED still lit up"* — a working observation that hides a silent miswire. + +You can opt out with `wire(*ports, dynamically_driven=True)` when the net is driven through the surrounding loop (op-amp positive feedback, RC timing networks). The opt-out is the designer's explicit assertion that the framework's static check should yield to a known dynamic driver. + +**First caught at:** [`hello_led/`](../demos/hello_led/) — the *floating resistor* near-miss snippet. + +## Rule 3 — Mandatory pins must be connected + +Some pins are declared `mandatory=True` because the part doesn't function without them — regulator inputs, op-amp V+ supplies, MCU VDD, MCU GND. Leaving any of them unwired raises [`UnconnectedPinError`](https://github.com/raeq/wirebench/blob/main/src/framework/errors.py). + +**Why:** A mandatory pin left in air leaves the silicon stage tied to nothing. The part either doesn't power up at all (no current path) or behaves unpredictably (floating reference). The bench equivalent is a board that arrives, populates, and refuses to come on — every solder joint is fine, but one wire was missing in the schematic. + +**First caught at:** [`5v_rail_power/`](../demos/5v_rail_power/) — the *floating regulator input* near-miss. + +## Rule 4 — Signal types stay matched + +Every port carries one of two signal families: `Analog` (continuous voltage) or `Digital` (logic level). Mixing them across a `wire()` raises [`SignalTypeMismatchError`](https://github.com/raeq/wirebench/blob/main/src/framework/errors.py). + +**Why:** An Analog continuous voltage and a Digital logic level can't share copper — one is a value in volts, the other a one-or-zero state. Converting between them needs an explicit interface (comparator, ADC, level-shifter). The framework refuses the direct wire so the conversion stays visible in the design. + +A BIDIR port declared as the generic `Analog` base class acts as a *conductor wildcard* — a piece of copper that takes on whatever type the rest of the wire imposes. This is what lets connector contacts and resistor terminals join either domain without breaking the discipline. + +**First caught at:** [`li_ion_fuel_gauge/`](../demos/li_ion_fuel_gauge/) — the *signal-type mismatch on the chip-enable pin* near-miss. + +## Rule 5 — Ground domains stay isolated + +A `wire()` (or any port-to-node attachment) that crosses two distinct `GroundDomain` instances raises [`DomainCrossingError`](https://github.com/raeq/wirebench/blob/main/src/framework/errors.py). + +**Why:** Two ground domains share a net only through an isolator (optocoupler, digital isolator, transformer). A direct wire would tie the references together and defeat the isolation that's the entire point of having two domains. In `isolated_rs232/`, the host-side and iso-side domains exist because the design's whole purpose is keeping them separate at high voltage; collapsing them with a stray jumper would silently undo the isolation. + +The framework allows isolator cells (e.g. `ISOW7841`, `Optocoupler`) to have ports in different domains as the legitimate way to bridge. + +**First caught at:** [`isolated_rs232/`](../demos/isolated_rs232/) — the *cross-domain wire* near-miss. + +## Rule 6 — Connectors only mate with their declared partner + +Every connector class declares its physical mate via `MATES_WITH`. Calling `mate(a, b)` where `type(b)` isn't `type(a).MATES_WITH` raises [`IncompatibleMateError`](https://github.com/raeq/wirebench/blob/main/src/framework/errors.py). + +**Why:** A USB-A receptacle and a TRRS audio jack have different shells, different pin counts, different pitches. Calling them mated is asserting a fact contradicted by the mechanical drawings. The bench equivalent is a parts order with the wrong cable type — the cable arrives and won't seat. The framework catches the mismatch when the code says `mate()`, before the order ships. + +**First caught at:** [`fan_cooling/`](../demos/fan_cooling/) — the *wasted parts order — wrong connector mated* near-miss. + +## Rule 7 — Connectors match on pin count and pitch + +Even when the connector families agree, two connectors with mismatched `pin_count` or `pitch_mm` won't physically seat. Mismatches raise [`PinCountMismatchError`](https://github.com/raeq/wirebench/blob/main/src/framework/errors.py) or [`PitchMismatchError`](https://github.com/raeq/wirebench/blob/main/src/framework/errors.py). + +**Why:** A 4-pin plug into a 5-pin receptacle leaves one pin unmated; a 2.54 mm plug into a 2.00 mm receptacle lands the pins between contacts, not on them. Either way the connection is physically incomplete. As with Rule 6, the bench equivalent is a parts order that arrives, doesn't fit, and goes back. + +**First caught at:** [`fan_cooling/`](../demos/fan_cooling/) — the *wrong-pin-count power plug* near-miss. + +## Rule 8 — Refdes uniqueness per circuit + +Every part on a real board carries a one-of-a-kind reference designator (`R1`, `U2`, `D3`). Two parts in the same `Circuit` sharing a refdes raises [`RefdesError`](https://github.com/raeq/wirebench/blob/main/src/framework/errors.py). + +**Why:** The refdes is the join key between the schematic, the BOM, the assembly drawing, and the manufactured PCB. If two parts share one, the BOM is ambiguous, the assembler doesn't know which footprint to populate, and the schematic and the layout disagree about which symbol corresponds to which footprint. A duplicate refdes is the documentation equivalent of a short circuit — two things claiming to be the same thing. + +**First caught at:** the framework rejects this at construction time on every demo; the surface comes up the first time you accidentally write `refdes_number=1` twice in your own design. + +## Rule 9 — Forbidden states stay forbidden + +Some logical states are valid wirings whose evaluation produces undefined or destructive output: SR latch with S=R=1, three-phase Hall pattern (0,0,0) or (1,1,1), half-bridge shoot-through. The framework refuses to evaluate them and raises [`ForbiddenStateError`](https://github.com/raeq/wirebench/blob/main/src/framework/errors.py). + +**Why:** Real silicon refuses to enter these states — or worse, enters them once and lets the smoke out. The SR latch with both inputs high is the canonical example: each NOR gate would force the other to zero, so neither output settles, and the chip behaviour is undefined. The framework catches this at `evaluate()` time and names the offending state with a suggested fix (drive the two inputs from mutually-exclusive sources, or use a latch type with no forbidden state). + +**First caught at:** [`water_alarm/`](../demos/water_alarm/) — the *locked-up latch* near-miss (S=R=1 on the NOR latch). + +## Rule 10 — `wire()` doesn't merge pre-existing nets + +A `wire()` whose ports are already on two distinct nodes would silently fuse independent design intent into one net. The framework refuses and raises [`NodeMergeError`](https://github.com/raeq/wirebench/blob/main/src/framework/errors.py). + +**Why:** Mergeing nets at construction time is a footgun for boards being composed from sub-circuits — a wire intended to extend one net could accidentally connect it to a parallel net the author hadn't realised was nearby. The framework requires the join to be explicit: refactor the code so the two nets are constructed together, or use a `Pin` / connector mate to bridge them deliberately. + +**First caught at:** framework-internal — surfaces when you copy-paste a wiring block and one of the destinations is already wired into the parent circuit. + +## Rule 11 — Empty wires aren't wires + +`wire()` called with zero or one port raises [`EmptyWireError`](https://github.com/raeq/wirebench/blob/main/src/framework/errors.py). + +**Why:** A wire with no endpoints connects nothing to nothing — it has no physical analogue on a real breadboard. A wire with one endpoint connects something to nothing — same problem. The framework refuses both shapes immediately so accidental `wire()` calls in a refactor don't sit silently in the source. + +**First caught at:** framework-internal — surfaces when refactoring removes a port from a `wire()` call and leaves the call with one or zero remaining ports. + +## Rule 12 — A wired chip can't be called standalone + +A chip whose ports are wired into a parent circuit must be driven through that circuit's `evaluate()`. Calling the chip's standalone `__call__` raises [`WiredChipCallError`](https://github.com/raeq/wirebench/blob/main/src/framework/errors.py). + +**Why:** A wired chip's pins are receiving values from the circuit; calling the chip directly with arguments would bypass those wired values and produce a result inconsistent with the rest of the design. The framework treats the chip's standalone callable as a *standalone interface* — useful only when the chip is being exercised in isolation. Once it's part of a wired circuit, the parent owns the evaluation order. + +**First caught at:** framework-internal — surfaces when you try to unit-test a chip that's already been instantiated as part of a composite. + +## How the rules compose + +The rules aren't independent — they reinforce each other. A `mate()` call validates Rule 6 + Rule 7 *and* invokes `wire()` per pin pair, which then validates Rules 1, 2, 4, and 5 on the bridged nets. A `Circuit` construction validates Rule 3 (mandatory pins) and Rule 8 (refdes uniqueness) *and* re-runs Rules 1 and 2 at the *logical net* level — catching shorts and floats that span multiple `wire()` calls through transparent conductors (Pins, connector contacts, transparent composites). + +The cumulative effect is the framework's value proposition: a design that passes construction is topologically buildable. Every wire in the source maps to a wire on the breadboard, every chip's pin numbers match the datasheet, every connector mates with what its mechanical drawing says it mates with, and no electrical impossibility the framework can detect at construction time is hiding behind a passing test run. + +For the abstract treatment of *why* these rules exist as a single coherent design, see [Design principles](design-principles.md). For graduated exposure to the rules through the demos that catch each one, see [Learning path](learning-path.md). diff --git a/mkdocs.yml b/mkdocs.yml index 5c66b18..525246f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -14,10 +14,11 @@ theme: nav: - Home: index.md + - Design principles: design-principles.md + - The rules: the-rules.md - Learning path: learning-path.md - Components: parts.md - Prevention benchmark: prevention-benchmark.md - - Design principles: design-principles.md - Component catalogue (narrative): component-library-data.md markdown_extensions: diff --git a/tests/docs/__init__.py b/tests/docs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/docs/test_rules_doc.py b/tests/docs/test_rules_doc.py new file mode 100644 index 0000000..6bbad3d --- /dev/null +++ b/tests/docs/test_rules_doc.py @@ -0,0 +1,161 @@ +"""Consistency tests for `docs/the-rules.md`. + +Keep the cumulative-rules narrative in sync with the exception +hierarchy: every user-facing rule exception is referenced in the doc, +every exception name mentioned in the doc resolves to a real class, +and every cross-link target (demo READMEs, `framework/errors.py`) +exists on disk. +""" +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from framework import errors as E + + +REPO_ROOT = Path(__file__).resolve().parents[2] +RULES_DOC = REPO_ROOT / 'docs' / 'the-rules.md' +LEARNING_PATH_DOC = REPO_ROOT / 'docs' / 'learning-path.md' +MKDOCS_YML = REPO_ROOT / 'mkdocs.yml' + + +# User-facing rule exceptions — those that represent a topology / +# physical-fidelity rule the framework enforces and that this doc is +# meant to teach. FormatError / UsageError variants that aren't part +# of the rule narrative (SaveError, LoadError, AmbiguousPinNameError, +# OrphanWireError, etc.) are intentionally omitted; they're real +# framework errors but not *rules* in the user-facing sense. +RULE_EXCEPTIONS = ( + 'ShortCircuitError', + 'FloatingNetError', + 'UnconnectedPinError', + 'NodeMergeError', + 'EmptyWireError', + 'SignalTypeMismatchError', + 'DomainCrossingError', + 'ForbiddenStateError', + 'RefdesError', + 'IncompatibleMateError', + 'PinCountMismatchError', + 'PitchMismatchError', + 'WiredChipCallError', +) + + +@pytest.fixture(scope='module') +def rules_doc_text() -> str: + return RULES_DOC.read_text() + + +def test_the_rules_doc_exists() -> None: + assert RULES_DOC.is_file(), f"{RULES_DOC} is missing" + + +@pytest.mark.parametrize('exception_name', RULE_EXCEPTIONS) +def test_every_rule_exception_is_named_in_the_doc( + exception_name: str, + rules_doc_text: str, +) -> None: + """A new rule exception added to the framework must also be + documented as a rule on the page — keeps the doc in sync as the + exception hierarchy evolves.""" + assert exception_name in rules_doc_text, ( + f"{exception_name} is in the RULE_EXCEPTIONS curated list " + f"but not mentioned in docs/the-rules.md. Either add a rule " + f"entry, or remove the class from the list if it's no longer " + f"user-facing." + ) + + +@pytest.mark.parametrize('exception_name', RULE_EXCEPTIONS) +def test_every_rule_exception_class_exists_in_framework( + exception_name: str, +) -> None: + """The curated rule-exception list must reflect real classes — if + a rename happened, the list and the doc both need updating.""" + cls = getattr(E, exception_name, None) + assert cls is not None, ( + f"{exception_name} is documented as a rule but doesn't exist " + f"in framework.errors — was it renamed?" + ) + assert isinstance(cls, type) and issubclass(cls, E.WirebenchError) + + +def test_every_exception_name_in_the_doc_resolves_to_a_class( + rules_doc_text: str, +) -> None: + """Any class name shaped like `XxxError` in the doc must point at + a real exception — catches typos like `ShortCircutError` (missing + `i`).""" + referenced = set(re.findall(r'\b([A-Z][A-Za-z0-9_]*Error)\b', rules_doc_text)) + for name in referenced: + assert hasattr(E, name), ( + f"Doc references {name!r} but no such exception exists in " + f"framework.errors — typo?" + ) + + +def test_every_demo_cross_link_resolves(rules_doc_text: str) -> None: + """`../demos//` links must point at directories that + actually exist.""" + demo_links = set(re.findall(r'\(\.\./demos/([a-z0-9_]+)/\)', rules_doc_text)) + assert demo_links, "No demo cross-links found — at minimum a few rules should anchor to demos" + for slug in demo_links: + path = REPO_ROOT / 'demos' / slug + assert path.is_dir(), ( + f"Doc links to ../demos/{slug}/ but {path} doesn't exist" + ) + + +def test_doc_cross_link_to_errors_source_resolves( + rules_doc_text: str, +) -> None: + """The doc links to the GitHub source of `framework/errors.py` — at + least the on-disk file it names must exist locally too, so the + `framework/errors.py` reference stays meaningful even before the + doc site is published.""" + assert 'src/framework/errors.py' in rules_doc_text + assert (REPO_ROOT / 'src' / 'framework' / 'errors.py').is_file() + + +# --------------------------------------------------------- learning-path + +def test_learning_path_cross_references_the_rules_doc() -> None: + """`docs/learning-path.md` must name `the-rules.md` so the two + pages cross-reference and the cumulative-rules property is + discoverable from either direction.""" + text = LEARNING_PATH_DOC.read_text() + assert 'the-rules.md' in text, ( + "learning-path.md must link to the-rules.md so the rule " + "narrative is reachable from the demo path." + ) + + +# --------------------------------------------------------------- mkdocs + +def test_the_rules_appears_in_mkdocs_navigation() -> None: + """The rules doc must be in the site nav so users browsing the + published docs can find it.""" + text = MKDOCS_YML.read_text() + assert 'the-rules.md' in text, ( + "mkdocs.yml must include the-rules.md in `nav:`." + ) + + +def test_mkdocs_orders_design_principles_before_rules_before_learning() -> None: + """The nav reads as a progression: abstract (Design principles) → + concrete enforcement (The rules) → graduated exposure (Learning + path). Each must appear in that order in the nav block.""" + text = MKDOCS_YML.read_text() + dp_pos = text.find('design-principles.md') + tr_pos = text.find('the-rules.md') + lp_pos = text.find('learning-path.md') + assert dp_pos != -1 and tr_pos != -1 and lp_pos != -1 + assert dp_pos < tr_pos < lp_pos, ( + f"mkdocs.yml ordering wrong: design-principles ({dp_pos}), " + f"the-rules ({tr_pos}), learning-path ({lp_pos}); expected " + f"design-principles → the-rules → learning-path." + ) From af8808d0317174d70dd55ad0ac79cd34b31151fc Mon Sep 17 00:00:00 2001 From: subzero Date: Wed, 20 May 2026 20:04:52 +0200 Subject: [PATCH 2/2] =?UTF-8?q?review:=20fix=20rule=E2=86=92demo=20attribu?= =?UTF-8?q?tions;=20deep-link=20to=20README=20anchors;=20strengthen=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer feedback addressed: **Accuracy.** Rule 3 (`UnconnectedPinError`) and Rule 6 (`IncompatibleMateError`) listed demos that don't actually catch those exceptions in their READMEs. Surveyed every demo README for the exceptions it raises and re-mapped each rule to the truly-first demo in learning-path order: - Rule 3 `UnconnectedPinError` → `penfold_light_switch/` (the "floating LDR" near-miss raises UnconnectedPinError; the LDR's terminals are mandatory), not `5v_rail_power/` or `digital_thermometer/`. - Rule 4 `SignalTypeMismatchError` → `penfold_light_switch/` (the "mismatched comparator input domain" near-miss), not `li_ion_fuel_gauge/`. - Rule 6 `IncompatibleMateError` → `water_alarm_split/` (the "wrong connector family" near-miss), not `fan_cooling/`. Updated `learning-path.md`'s *First catches* column to match. **Traceability.** Per the spec — "link to the demo's *what this design is protected from* sidebar" — every `First caught at:` entry now deep-links to the specific README#anchor of the near-miss section, not just the demo folder. Anchors generated from the section heading text using GitHub's slug algorithm. **Test coverage.** Strengthened `test_every_demo_cross_link_resolves` to compute each README's GitHub-style heading anchors and verify every `#anchor` in `the-rules.md` points at a real heading. Catches typos in the slug and renames of section headings on the demo side. **Intro overclaim.** Softened the cumulative-progress claim — the intro previously asserted you'd see "every rule on this page" caught in real demos by working through a subset of demos, but Rules 8, 10, 11, 12 are framework-internal refusals with no demo anchor. Now explicitly: "the demo-anchored rules (Rules 1–7, 9)". **Typo.** "Mergeing" → "Merging" on Rule 10's *Why:* line. Suite: 4795 passed, mypy clean. --- docs/learning-path.md | 44 +++++++++++------------ docs/the-rules.md | 26 +++++++------- tests/docs/test_rules_doc.py | 67 +++++++++++++++++++++++++++++++++--- 3 files changed, 97 insertions(+), 40 deletions(-) diff --git a/docs/learning-path.md b/docs/learning-path.md index bc91969..aa0392f 100644 --- a/docs/learning-path.md +++ b/docs/learning-path.md @@ -18,28 +18,28 @@ If you've never lit an LED with a battery, start with [the project's Hello World The *first catches* column names rules from [`the-rules.md`](the-rules.md) that this demo is the first place to surface — i.e. work the demos top-to-bottom and you'll see the framework refuse each rule in a real circuit by the time you reach the demo it's listed against. -| # | Demo | What it teaches | First catches | -|----|------------------------------------------------------------------------|------------------------------------------------------------------------------|----------------------------| -| 1 | [`hello_led/`](../demos/hello_led/) | The minimal viable circuit — one LED, one resistor, two rails. Reading point.| [Rules 1, 2](the-rules.md) | -| 2 | [`penfold_light_switch/`](../demos/penfold_light_switch/) | First sensor circuit. LDR + comparator + transistor switch. Penfold BP107 P3.| — | -| 3 | [`water_alarm/`](../demos/water_alarm/) | Composing chips, wiring rails, latching logic. Four chips, two LEDs. | [Rule 9](the-rules.md) | -| 4 | [`penfold_reaction_game/`](../demos/penfold_reaction_game/) | Sequential digital — counter ring + button-stops-clock topology. Penfold P22.| — | -| 5 | [`dice/`](../demos/dice/) | Classic 555 + 4017 + diode-OR matrix. Recognisable hobbyist staple. | — | -| 6 | [`digital_thermometer/`](../demos/digital_thermometer/) | First MCU project. ATmega328P + DHT11 + 7-seg display. Firmware-as-cell. | [Rule 3](the-rules.md) | -| 7 | [`penfold_one_second_timer/`](../demos/penfold_one_second_timer/) | Op-amp relaxation oscillator with hysteresis. Penfold BP107 P8. | — | -| 8 | [`penfold_metronome/`](../demos/penfold_metronome/) | NE555 astable + speaker — the other classical astable. Penfold BP107 P9. | — | -| 9 | [`penfold_warbling_doorbuzzer/`](../demos/penfold_warbling_doorbuzzer/)| Oscillator composition — slow gates fast. Penfold BP107 P16. | — | -| 10 | [`doorbell_protector/`](../demos/doorbell_protector/) | Two-555 monostable with transistor switching and a relay. | — | -| 11 | [`fan_cooling/`](../demos/fan_cooling/) | First `Board` demo. TMP302 + MOSFET-switched fan. Connectors that mate. | [Rules 6, 7](the-rules.md) | -| 12 | [`backup_power/`](../demos/backup_power/) | TI Designs TIDA-03031. Three-stage power architecture (eFuse + boost + buck).| — | -| 13 | [`water_alarm_split/`](../demos/water_alarm_split/) | Same circuit as #3 but split across two boards via `mate()`. HAT pattern. | — | -| 14 | [`bldc_motor/`](../demos/bldc_motor/) | ATmega328P + DRV8313 + Hall sensors. Three-phase commutation. | — | -| 15 | [`isolated_rs232/`](../demos/isolated_rs232/) | TIDA-01230. Cross-domain isolation — first demo to exercise `GroundDomain`. | [Rule 5](the-rules.md) | -| 16 | [`li_ion_fuel_gauge/`](../demos/li_ion_fuel_gauge/) | TIDA-00594. BQ27546-G1 fuel gauge with sense resistor + thermistor. | [Rule 4](the-rules.md) | -| 17 | [`penfold_fuzz_unit/`](../demos/penfold_fuzz_unit/) | Audio domain — op-amp + clipping diodes. Guitar fuzz pedal. Penfold P30. | — | -| 18 | [`penfold_crystal_set/`](../demos/penfold_crystal_set/) | Passive-only RF — no Rail, no battery. Boundary case. Penfold BP107 P27. | — | - -Rule 8 (refdes uniqueness) fires the first time you accidentally reuse `refdes_number=` in your own design — it's a construction-time check on every demo but doesn't appear as a near-miss snippet in any. Rules 10, 11, and 12 are framework-internal refusals that surface during refactors rather than in a specific demo. The full table of all twelve rules is on [`the-rules.md`](the-rules.md). +| # | Demo | What it teaches | First catches | +|----|------------------------------------------------------------------------|------------------------------------------------------------------------------|-------------------------------| +| 1 | [`hello_led/`](../demos/hello_led/) | The minimal viable circuit — one LED, one resistor, two rails. Reading point.| [Rules 1, 2](the-rules.md) | +| 2 | [`penfold_light_switch/`](../demos/penfold_light_switch/) | First sensor circuit. LDR + comparator + transistor switch. Penfold BP107 P3.| [Rules 3, 4](the-rules.md) | +| 3 | [`water_alarm/`](../demos/water_alarm/) | Composing chips, wiring rails, latching logic. Four chips, two LEDs. | [Rule 9](the-rules.md) | +| 4 | [`penfold_reaction_game/`](../demos/penfold_reaction_game/) | Sequential digital — counter ring + button-stops-clock topology. Penfold P22.| — | +| 5 | [`dice/`](../demos/dice/) | Classic 555 + 4017 + diode-OR matrix. Recognisable hobbyist staple. | — | +| 6 | [`digital_thermometer/`](../demos/digital_thermometer/) | First MCU project. ATmega328P + DHT11 + 7-seg display. Firmware-as-cell. | — | +| 7 | [`penfold_one_second_timer/`](../demos/penfold_one_second_timer/) | Op-amp relaxation oscillator with hysteresis. Penfold BP107 P8. | — | +| 8 | [`penfold_metronome/`](../demos/penfold_metronome/) | NE555 astable + speaker — the other classical astable. Penfold BP107 P9. | — | +| 9 | [`penfold_warbling_doorbuzzer/`](../demos/penfold_warbling_doorbuzzer/)| Oscillator composition — slow gates fast. Penfold BP107 P16. | — | +| 10 | [`doorbell_protector/`](../demos/doorbell_protector/) | Two-555 monostable with transistor switching and a relay. | — | +| 11 | [`fan_cooling/`](../demos/fan_cooling/) | First `Board` demo. TMP302 + MOSFET-switched fan. Connectors that mate. | [Rule 7](the-rules.md) | +| 12 | [`backup_power/`](../demos/backup_power/) | TI Designs TIDA-03031. Three-stage power architecture (eFuse + boost + buck).| — | +| 13 | [`water_alarm_split/`](../demos/water_alarm_split/) | Same circuit as #3 but split across two boards via `mate()`. HAT pattern. | [Rule 6](the-rules.md) | +| 14 | [`bldc_motor/`](../demos/bldc_motor/) | ATmega328P + DRV8313 + Hall sensors. Three-phase commutation. | — | +| 15 | [`isolated_rs232/`](../demos/isolated_rs232/) | TIDA-01230. Cross-domain isolation — first demo to exercise `GroundDomain`. | [Rule 5](the-rules.md) | +| 16 | [`li_ion_fuel_gauge/`](../demos/li_ion_fuel_gauge/) | TIDA-00594. BQ27546-G1 fuel gauge with sense resistor + thermistor. | — | +| 17 | [`penfold_fuzz_unit/`](../demos/penfold_fuzz_unit/) | Audio domain — op-amp + clipping diodes. Guitar fuzz pedal. Penfold P30. | — | +| 18 | [`penfold_crystal_set/`](../demos/penfold_crystal_set/) | Passive-only RF — no Rail, no battery. Boundary case. Penfold BP107 P27. | — | + +Rules 8, 10, 11, and 12 are framework-internal refusals — they fire during refactors and new-design construction rather than in any specific demo's near-miss snippet. The full table of all twelve rules is on [`the-rules.md`](the-rules.md). Cross-referencing the other way: [`the-rules.md`](the-rules.md) lists, for each rule, the demo where you'll first see it caught — so you can read either page first and find your way back to the other. diff --git a/docs/the-rules.md b/docs/the-rules.md index 28a3954..d057f38 100644 --- a/docs/the-rules.md +++ b/docs/the-rules.md @@ -7,9 +7,9 @@ This page is the index. For each rule: - **The rule itself**, one sentence. - **Why** — the physical-world referent. - **What fires** — the exception class wirebench raises ([source](https://github.com/raeq/wirebench/blob/main/src/framework/errors.py)). -- **First caught at** — the demo where you'll meet this rule for the first time if you walk the [learning path](learning-path.md) top to bottom. +- **First caught at** — the demo where you'll meet this rule for the first time if you walk the [learning path](learning-path.md) top to bottom (when the rule has a demo anchor). -By the time you've worked through `hello_led/` + `water_alarm/` + `5v_rail_power/` + `digital_thermometer/` + `fan_cooling/` + `isolated_rs232/` + `li_ion_fuel_gauge/`, you've seen the framework catch every rule on this page in a real demo. The rules aren't aspirational — they're the load-bearing checks that keep "the code matches the breadboard" honest. +By the time you've worked through `hello_led/` + `penfold_light_switch/` + `water_alarm/` + `fan_cooling/` + `water_alarm_split/` + `isolated_rs232/` + `li_ion_fuel_gauge/`, you've seen the framework catch the demo-anchored rules in real designs (Rules 1–7, 9). The remaining rules (8, 10–12) are framework-internal refusals that surface during refactors and new-design construction rather than in a specific demo's near-miss snippet — they're listed for completeness but don't have a *first-caught-at* demo. ## Rule 1 — One driver per logical net @@ -17,7 +17,7 @@ If two ports declared `Direction.OUT` end up on the same logical net, `wire()` ( **Why:** Two OUT-direction ports on one net fight each other on the copper. Current sinks through the losing output stage until the FETs overheat. Real silicon has one driver per shared conductor. -**First caught at:** [`hello_led/`](../demos/hello_led/) — the *shorted supply* near-miss snippet. +**First caught at:** [`hello_led/` — *A shorted supply*](../demos/hello_led/README.md#a-shorted-supply). ## Rule 2 — Every BIDIR-only net needs a driver @@ -27,15 +27,15 @@ A net touched only by passive BIDIR ports (resistor terminals, capacitor leads, You can opt out with `wire(*ports, dynamically_driven=True)` when the net is driven through the surrounding loop (op-amp positive feedback, RC timing networks). The opt-out is the designer's explicit assertion that the framework's static check should yield to a known dynamic driver. -**First caught at:** [`hello_led/`](../demos/hello_led/) — the *floating resistor* near-miss snippet. +**First caught at:** [`hello_led/` — *A floating resistor*](../demos/hello_led/README.md#a-floating-resistor). ## Rule 3 — Mandatory pins must be connected -Some pins are declared `mandatory=True` because the part doesn't function without them — regulator inputs, op-amp V+ supplies, MCU VDD, MCU GND. Leaving any of them unwired raises [`UnconnectedPinError`](https://github.com/raeq/wirebench/blob/main/src/framework/errors.py). +Some pins are declared `mandatory=True` because the part doesn't function without them — regulator inputs, op-amp V+ supplies, MCU VDD, MCU GND, two-lead passive terminals. Leaving any of them unwired raises [`UnconnectedPinError`](https://github.com/raeq/wirebench/blob/main/src/framework/errors.py). **Why:** A mandatory pin left in air leaves the silicon stage tied to nothing. The part either doesn't power up at all (no current path) or behaves unpredictably (floating reference). The bench equivalent is a board that arrives, populates, and refuses to come on — every solder joint is fine, but one wire was missing in the schematic. -**First caught at:** [`5v_rail_power/`](../demos/5v_rail_power/) — the *floating regulator input* near-miss. +**First caught at:** [`penfold_light_switch/` — *A floating LDR*](../demos/penfold_light_switch/README.md#a-floating-ldr) (the LDR's two terminals are mandatory; forgetting one raises `UnconnectedPinError`). ## Rule 4 — Signal types stay matched @@ -45,7 +45,7 @@ Every port carries one of two signal families: `Analog` (continuous voltage) or A BIDIR port declared as the generic `Analog` base class acts as a *conductor wildcard* — a piece of copper that takes on whatever type the rest of the wire imposes. This is what lets connector contacts and resistor terminals join either domain without breaking the discipline. -**First caught at:** [`li_ion_fuel_gauge/`](../demos/li_ion_fuel_gauge/) — the *signal-type mismatch on the chip-enable pin* near-miss. +**First caught at:** [`penfold_light_switch/` — *A mismatched comparator input domain*](../demos/penfold_light_switch/README.md#a-mismatched-comparator-input-domain). ## Rule 5 — Ground domains stay isolated @@ -55,7 +55,7 @@ A `wire()` (or any port-to-node attachment) that crosses two distinct `GroundDom The framework allows isolator cells (e.g. `ISOW7841`, `Optocoupler`) to have ports in different domains as the legitimate way to bridge. -**First caught at:** [`isolated_rs232/`](../demos/isolated_rs232/) — the *cross-domain wire* near-miss. +**First caught at:** [`isolated_rs232/` — *A cross-domain wire*](../demos/isolated_rs232/README.md#a-cross-domain-wire). ## Rule 6 — Connectors only mate with their declared partner @@ -63,7 +63,7 @@ Every connector class declares its physical mate via `MATES_WITH`. Calling `mate **Why:** A USB-A receptacle and a TRRS audio jack have different shells, different pin counts, different pitches. Calling them mated is asserting a fact contradicted by the mechanical drawings. The bench equivalent is a parts order with the wrong cable type — the cable arrives and won't seat. The framework catches the mismatch when the code says `mate()`, before the order ships. -**First caught at:** [`fan_cooling/`](../demos/fan_cooling/) — the *wasted parts order — wrong connector mated* near-miss. +**First caught at:** [`water_alarm_split/` — *A wasted parts order — wrong connector family*](../demos/water_alarm_split/README.md#a-wasted-parts-order--wrong-connector-family). ## Rule 7 — Connectors match on pin count and pitch @@ -71,7 +71,7 @@ Even when the connector families agree, two connectors with mismatched `pin_coun **Why:** A 4-pin plug into a 5-pin receptacle leaves one pin unmated; a 2.54 mm plug into a 2.00 mm receptacle lands the pins between contacts, not on them. Either way the connection is physically incomplete. As with Rule 6, the bench equivalent is a parts order that arrives, doesn't fit, and goes back. -**First caught at:** [`fan_cooling/`](../demos/fan_cooling/) — the *wrong-pin-count power plug* near-miss. +**First caught at:** [`fan_cooling/` — *A wasted parts order — wrong-pin-count power plug*](../demos/fan_cooling/README.md#a-wasted-parts-order--wrong-pin-count-power-plug). ## Rule 8 — Refdes uniqueness per circuit @@ -79,7 +79,7 @@ Every part on a real board carries a one-of-a-kind reference designator (`R1`, ` **Why:** The refdes is the join key between the schematic, the BOM, the assembly drawing, and the manufactured PCB. If two parts share one, the BOM is ambiguous, the assembler doesn't know which footprint to populate, and the schematic and the layout disagree about which symbol corresponds to which footprint. A duplicate refdes is the documentation equivalent of a short circuit — two things claiming to be the same thing. -**First caught at:** the framework rejects this at construction time on every demo; the surface comes up the first time you accidentally write `refdes_number=1` twice in your own design. +**First caught at:** framework-internal — surfaces the first time you accidentally write `refdes_number=1` twice in your own design. ## Rule 9 — Forbidden states stay forbidden @@ -87,13 +87,13 @@ Some logical states are valid wirings whose evaluation produces undefined or des **Why:** Real silicon refuses to enter these states — or worse, enters them once and lets the smoke out. The SR latch with both inputs high is the canonical example: each NOR gate would force the other to zero, so neither output settles, and the chip behaviour is undefined. The framework catches this at `evaluate()` time and names the offending state with a suggested fix (drive the two inputs from mutually-exclusive sources, or use a latch type with no forbidden state). -**First caught at:** [`water_alarm/`](../demos/water_alarm/) — the *locked-up latch* near-miss (S=R=1 on the NOR latch). +**First caught at:** [`water_alarm/` — *A locked-up latch*](../demos/water_alarm/README.md#a-locked-up-latch) (S=R=1 on the NOR latch). ## Rule 10 — `wire()` doesn't merge pre-existing nets A `wire()` whose ports are already on two distinct nodes would silently fuse independent design intent into one net. The framework refuses and raises [`NodeMergeError`](https://github.com/raeq/wirebench/blob/main/src/framework/errors.py). -**Why:** Mergeing nets at construction time is a footgun for boards being composed from sub-circuits — a wire intended to extend one net could accidentally connect it to a parallel net the author hadn't realised was nearby. The framework requires the join to be explicit: refactor the code so the two nets are constructed together, or use a `Pin` / connector mate to bridge them deliberately. +**Why:** Merging nets at construction time is a footgun for boards being composed from sub-circuits — a wire intended to extend one net could accidentally connect it to a parallel net the author hadn't realised was nearby. The framework requires the join to be explicit: refactor the code so the two nets are constructed together, or use a `Pin` / connector mate to bridge them deliberately. **First caught at:** framework-internal — surfaces when you copy-paste a wiring block and one of the destinations is already wired into the parent circuit. diff --git a/tests/docs/test_rules_doc.py b/tests/docs/test_rules_doc.py index 6bbad3d..f5eb4cb 100644 --- a/tests/docs/test_rules_doc.py +++ b/tests/docs/test_rules_doc.py @@ -98,12 +98,69 @@ def test_every_exception_name_in_the_doc_resolves_to_a_class( ) +def _github_anchor(heading: str) -> str: + """Mimic GitHub's heading-anchor algorithm so the test can compare + a link's `#anchor` fragment against a heading text taken from a + demo README. + + Rules (matching GitHub's behaviour for our use cases): + - lowercase + - keep word characters, spaces, and hyphens; drop everything else + (em-dashes, periods, parentheses, apostrophes all drop) + - replace each space with a hyphen + Consecutive hyphens are preserved (e.g. `order — wrong` becomes + `order--wrong`). + """ + text = heading.strip().lower() + text = re.sub(r'[^\w\s-]', '', text) + text = text.replace(' ', '-') + return text + + +def _readme_anchors(readme_path: Path) -> set[str]: + """Every GitHub-style anchor produced by an `## …` / `### …` + heading in the given README.""" + anchors: set[str] = set() + for line in readme_path.read_text().splitlines(): + m = re.match(r'^#{2,6}\s+(.+?)\s*$', line) + if m: + anchors.add(_github_anchor(m.group(1))) + return anchors + + def test_every_demo_cross_link_resolves(rules_doc_text: str) -> None: - """`../demos//` links must point at directories that - actually exist.""" - demo_links = set(re.findall(r'\(\.\./demos/([a-z0-9_]+)/\)', rules_doc_text)) - assert demo_links, "No demo cross-links found — at minimum a few rules should anchor to demos" - for slug in demo_links: + """`../demos//README.md#` links must resolve: the + README file exists and the anchor matches a heading in it. Plain + `../demos//` directory links (no anchor) must point at a + real demo directory.""" + # Anchor-bearing README links. + readme_links = re.findall( + r'\(\.\./demos/([a-z0-9_]+)/README\.md#([a-z0-9-]+)\)', + rules_doc_text, + ) + assert readme_links, ( + "the-rules.md should link to demo README sections (the " + "*what this design is protected from* near-miss snippets), " + "not just demo folders — that's what gives rule entries their " + "first-caught traceability." + ) + for slug, anchor in readme_links: + readme = REPO_ROOT / 'demos' / slug / 'README.md' + assert readme.is_file(), ( + f"Doc links to ../demos/{slug}/README.md but {readme} " + f"doesn't exist" + ) + anchors = _readme_anchors(readme) + assert anchor in anchors, ( + f"Doc links to {readme.name}#{anchor} but no heading in " + f"that README produces that GitHub anchor. Available " + f"anchors: {sorted(anchors)}" + ) + # Plain directory links (no fragment) — also verify the dirs exist. + plain_dir_links = set(re.findall( + r'\(\.\./demos/([a-z0-9_]+)/\)', rules_doc_text, + )) + for slug in plain_dir_links: path = REPO_ROOT / 'demos' / slug assert path.is_dir(), ( f"Doc links to ../demos/{slug}/ but {path} doesn't exist"