From 4979c76a40eed6eb8f6e5ca4e84c796b95e87479 Mon Sep 17 00:00:00 2001 From: subzero Date: Wed, 20 May 2026 20:21:26 +0200 Subject: [PATCH 1/2] framework: contributor scaffold + per-rule physical justifications (Phase 2b.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reviewer's "every class must obey project-specific rules" critique is a contributor complaint, not a user complaint. A user wires existing parts together; the friction lives in adding a new component class — `__slots__`, all six required ClassVars (REFDES_PREFIX, FOOTPRINT, PIN_NUMBERS, LAYOUT, VERIFY, GOTCHAS), @register, refdes validation, port shape, test stub. Two responses, both keeping the rules: **Scaffold script** — `scripts/scaffold_component.py`. A CLI that takes a brief part spec (name, kind, refdes prefix, footprint, pins, description) and emits a complete component-class file plus a matching test stub. The scaffold's output passes every framework rule by construction; the contributor fills in only the part-specific bits (real `evaluate()` / `__call__()` logic, VERIFY / GOTCHAS strings, layout descriptor). Supports `passive` and `chip` kinds today — the two shapes new contributors reach for first. Also re-exports the new class from the kind's `__init__.py`. **Per-rule physical justifications.** `docs/design-principles.md` gains a *Rules for component-class authors* section that mirrors the project CLAUDE.md's component-class rules with a `Why:` line keyed to a physical referent for each. The CLAUDE.md edits land locally (the file is gitignored per project policy) so Claude sessions see the same `Why:` content during contributor-side work. **CONTRIBUTING.md** documents the scaffold as the recommended path for adding new components, with the manual recipe kept as a fallback. **Tests** in `tests/contributor/`: - `test_scaffold_output.py` — every emitted file `ast.parse`s, declares `__slots__`, contains all six required ClassVars, registers with the framework registry, updates `__init__.py`'s re-exports, constructs cleanly, refuses to overwrite existing files. Covers both passive and chip kinds. - `test_scaffold_emits_tests.py` — the generated test stub passes when run against the generated class (every test function called in-process; unique class names per test to avoid registry collisions). - Helper module loads scaffold modules via `importlib.util.spec_from_file_location` so tmp_path output doesn't pollute the real `components.passives.*` namespace. Suite: 4813 passed (18 new), mypy clean. --- CONTRIBUTING.md | 55 +- docs/design-principles.md | 76 +++ scripts/scaffold_component.py | 569 ++++++++++++++++++ tests/contributor/__init__.py | 0 tests/contributor/_scaffold_harness.py | 123 ++++ .../contributor/test_scaffold_emits_tests.py | 82 +++ tests/contributor/test_scaffold_output.py | 153 +++++ 7 files changed, 1057 insertions(+), 1 deletion(-) create mode 100755 scripts/scaffold_component.py create mode 100644 tests/contributor/__init__.py create mode 100644 tests/contributor/_scaffold_harness.py create mode 100644 tests/contributor/test_scaffold_emits_tests.py create mode 100644 tests/contributor/test_scaffold_output.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 85b6689..f35c9d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,7 +43,60 @@ for the full pattern. ## Adding a new part -Take `LM7805` as the canonical example for a linear regulator: +The recommended path for a new component is the scaffold script. It +machine-applies every contributor-side rule (`__slots__`, the six +required ClassVars, `@register`, refdes validation, port shape, test +stub) so you can focus on the part-specific specification. + +```bash +uv run scripts/scaffold_component.py \ + --name LM7806 \ + --kind chip \ + --refdes-prefix U \ + --footprint "Package_TO_SOT_THT:TO-220-3_Vertical" \ + --pins "vin:in:Analog,gnd:in:Analog,vout:out:Analog" \ + --description "6 V linear regulator — TO-220 fixed-output." +``` + +This emits two files: + +- `src/components/chips/lm7806.py` — the component class, with + `__slots__`, every required ClassVar (`REFDES_PREFIX`, `FOOTPRINT`, + `PIN_NUMBERS`, `LAYOUT`, `VERIFY`, `GOTCHAS`), `@register`, a + refdes-validating `__init__`, and a placeholder `evaluate()` / + `__call__()` shape that drives every OUT pin so the framework's + *OUT pin must be driven* invariant passes by default. +- `tests/components/test_lm7806.py` — a construction-shape test stub + that asserts the class refdes, port surface, and per-pin direction + + signal-type values. + +The scaffold also re-exports the new class from the kind's +`__init__.py` so `from components.chips import LM7806` works. + +You then fill in: + +1. The class docstring — the part's real behavioural description, + pin table, operating range, framework-relevant gotchas. +2. The `VERIFY` strings — multimeter / bench-test instructions the + builder runs *before* powering the board. +3. The `GOTCHAS` strings — assembly-time warnings the + `assembly_guide` exporter surfaces to the breadboard builder. +4. The `LAYOUT` descriptor (axial_2lead, dip, qfp, …) so the + breadboard SVG visualiser knows how to draw the part. +5. The real `evaluate()` / `__call__()` logic. For chips with OUT + pins, the canonical pattern is a *concept cell* under + `src/components/chips/concepts/`: instantiate the cell in + `__init__`, wire it to the OUT pin's `.internal` face, let auto- + collect pick it up via `self.cell = MyConcept(...)`. + +Supported `--kind` values today are `passive` and `chip`. For other +families (`connector`, `diode`, `transistor`, `relay`, `transducer`), +the base classes have shapes too varied to template usefully — copy +an existing example (`src/components/diodes/`, `src/components/connectors/`, +etc.) and adapt. The framework rules apply equally to hand-written +components. + +If you're not using the scaffold, the manual steps are: 1. Pick the right base class: - `Chip` for ICs (anything with internal logic + a pin table) diff --git a/docs/design-principles.md b/docs/design-principles.md index ef8be7f..aff8d27 100644 --- a/docs/design-principles.md +++ b/docs/design-principles.md @@ -162,6 +162,82 @@ The principles above appear as specific patterns throughout the code: Each pattern is a specific application of the central commitment: *every line of code maps to a physical operation*. +## Rules for component-class authors + +If you're adding a new component to `src/components/`, the [scaffold script](#scaffolding-a-new-component) machine-applies most of the rules below. The list is here so the *why* of each rule stays visible — if a refactor tempts you to bypass one, the physical referent is the test. + +### Physical fidelity is primary + +Each component exposes only the interfaces a real component has. If you couldn't do it with a soldering iron, you cannot do it in code. + +**Why:** Every other rule below is a corollary of this one. A class that surfaces an interface no physical part offers is no longer modelling the part — it's modelling something convenient for software, which is what the framework was built to refuse. + +### Callable components (functor pattern) + +Every component implements `__call__` as its sole signal interface. Invoke components like functions — input in, output out — because that is what physical components are. + +**Why:** A real component has no API beyond its electrical interface. A resistor has two leads; you apply a signal, you read a signal. Methods named `apply()` or `update()` are software-layer convenience that has no physical analogue — and once present, they're how the framework's discipline starts leaking. + +### No direct state manipulation + +Component state may only change via the designated signal path (`__call__`). Never add setters, mutator methods, or public attributes that allow state to be written directly from outside. + +**Why:** A real LED isn't lit because something wrote `True` to it — it's lit because current flows from anode to cathode. Setters that bypass the signal path break the *the code matches the breadboard* contract: a `wire()` failure can no longer guarantee the component is in a defined state. + +### Explicit wiring in composite components + +When composing components, the wiring must be written out explicitly and directionally — each line is a wire, signal flows one way. No component knows about any other; they are connected only by the composite's `__call__` method. + +**Why:** Every `wire()` call is a jumper on the breadboard. Hiding the wiring inside a helper or a loop hides the topology — and the framework's whole construction-time validation pass walks the explicit `wire()` calls to detect shorts, floats, and ground-domain crossings. Implicit wiring would have nothing to walk. + +### `__slots__` on every component + +All component classes must declare `__slots__`. Physical components cannot grow new pins at runtime. + +**Why:** A real chip's pinout is fixed at the factory. A class without `__slots__` can grow attributes (and ports) at runtime through accidental assignment — which would be a part that magically grows a third terminal once installed. The framework relies on the pin count being immutable when it validates net topology; `__slots__` is what makes that load-bearing. + +### Invalid states must raise + +Hardware has forbidden states (e.g. S=R=1 on an SR latch). Model them with leaves from `framework.errors` — `ForbiddenStateError`, `PartParameterError`, `PartConfigurationError`. Never raise bare `ValueError` / `TypeError`. + +**Why:** Real silicon doesn't ignore S=R=1 on an SR latch — it enters an undefined state that often costs hardware. The framework's job is to refuse those states with a specific, named exception so the designer learns *what's wrong physically*, not just *something raised*. Bare `ValueError` collapses every defect class into one bucket and erases the teaching the hierarchy was built to deliver. + +### Signal types: always `Analog` and `Digital` + +Ports, wiring, and signal handling must use `Analog` / `Digital` — never raw `float` / `bool`. + +**Why:** Real copper carries either a continuous voltage or a logic level — never both, never something in between. Tagging ports with `Analog` / `Digital` is how the framework refuses cross-type wires at construction time; using raw `bool` / `float` collapses both worlds into one untyped substrate and the check disappears. + +### Output polarity matches physical pin behaviour + +Model the pin voltage, not the internal device state. An open-collector output is LOW when conducting and HIGH when off — drive `False` for the conducting case. + +**Why:** The framework models pins, not silicon insides. A downstream component reading `True` from an open-collector pin should see HIGH (the rest state) — which is what a multimeter would read at that pin. Inverting the polarity inside the part to make the wiring "look right" hides a real-world inversion the schematic still has to show. + +### Hardware-pin-name parameters + +`__call__` parameter names must be hardware pin names (`s`, `r`, `v_plus`) — not application-layer names (`low`, `high`, `sensor`). + +**Why:** Pin names are the join between the model and the datasheet. A reader cross-referencing the chip's pinout should land on the same identifier wirebench uses; software-layer names break that cross-reference and force the reader to translate. + +### What not to do + +- **No convenience methods that bypass the signal path.** + *Why:* A real part has no API beyond its electrical interface. Convenience methods are a software-layer concern; once present, they're how the *callable components* discipline starts leaking and downstream code grows habits that no longer match what the breadboard does. + +- **No logging, observers, or callbacks inside component classes.** + *Why:* A physical resistor has no logger. Observers and callbacks belong to the surrounding orchestration (the harness, the simulator, the visualiser) — not to the part itself. Putting them on the component invites a class hierarchy that diverges from physical reality. + +- **No inheritance between component types — compose, don't inherit.** + *Why:* Physical parts don't inherit. A comparator and an op-amp share a package shape and some pin-name conventions, but they're distinct silicon. Inheritance would assert an *is-a* relationship the datasheets don't claim; composition (one part instantiating another as an internal cell) matches how real parts are designed. + +- **No default power-on state unless the real part has one.** + *Why:* A real chip at power-on may be in any state its silicon allows; some latch types come up in a defined state, most don't. A scaffold that initialises every component to a defined state lies about the bench reality — the user's downstream check for "is this in the right state yet?" never sees the indeterminate-at-power-on case the real chip exhibits. + +### Scaffolding a new component + +`scripts/scaffold_component.py` machine-applies the rules above. See [`CONTRIBUTING.md`](https://github.com/raeq/wirebench/blob/main/CONTRIBUTING.md#adding-a-new-part) for the invocation; the scaffold's output passes every framework rule by construction, so the contributor only fills in part-specific specification (pin logic, `VERIFY` / `GOTCHAS` strings, layout descriptor). + ## Further reading - The source code at [github.com/raeq/wirebench](https://github.com/raeq/wirebench) — every framework primitive is annotated with comments explaining the design decisions and trade-offs that produced its current shape. diff --git a/scripts/scaffold_component.py b/scripts/scaffold_component.py new file mode 100755 index 0000000..a13f0d5 --- /dev/null +++ b/scripts/scaffold_component.py @@ -0,0 +1,569 @@ +#!/usr/bin/env python3 +"""Scaffold a new wirebench component class + matching test stub. + +The framework's contributor-side discipline (`__slots__`, every +required ClassVar, callable interface, port shape, registry +decorator, refdes validation) is mechanical — and getting it right +from memory is the on-ramp friction the *strictness as adoption +friction* feedback named. This script machine-applies that +boilerplate. The contributor then fills in: + +- The pin-specific port logic that the framework can't infer. +- The teaching strings (`VERIFY`, `GOTCHAS`) that need human + judgement. +- Any cell composition for chips with OUT pins. + +Usage: + + uv run scripts/scaffold_component.py \\ + --name MyPart \\ + --kind passive \\ + --refdes-prefix R \\ + --footprint "Resistor_SMD:R_0603_1608Metric" \\ + --pins "t1:bidir:Analog,t2:bidir:Analog" \\ + --description "My example passive" + +Or interactively: + + uv run scripts/scaffold_component.py --interactive + +Supported `--kind` values: `passive`, `chip`. Other framework +component families (connector, diode, transistor, relay, transducer) +inherit through dedicated base classes whose shapes are too varied to +template usefully — for those, copy an existing example +(`src/components/connectors/*.py`, `src/components/diodes/*.py`, etc.) +and adapt. The discipline checks in this script apply equally to +hand-written components. + +The scaffold writes to `src/components/s/.py` and +`tests/components/test_.py` by default; pass +`--output-root ` to redirect (used by the contributor test +suite). +""" +from __future__ import annotations + +import argparse +import re +import sys +from dataclasses import dataclass +from pathlib import Path + + +# ---------------------------------------------------------------- types + + +@dataclass(frozen=True) +class PinSpec: + name: str + direction: str # 'in' / 'out' / 'bidir' + signal_type: str # 'Analog' / 'Digital' + + +@dataclass(frozen=True) +class ComponentSpec: + class_name: str + kind: str # 'passive' | 'chip' + refdes_prefix: str + footprint: str + pins: tuple[PinSpec, ...] + description: str + + +# --------------------------------------------------------- parsing + + +_DIRECTIONS = {'in', 'out', 'bidir'} +_SIGNAL_TYPES = {'Analog', 'Digital'} +_KINDS = {'passive', 'chip'} + + +def _snake_case(name: str) -> str: + # CamelCase → snake_case; handles consecutive caps (LM7805 → lm7805). + s = re.sub(r'(? tuple[PinSpec, ...]: + """Parse `name:direction:signal_type,name:direction:signal_type` syntax.""" + pins: list[PinSpec] = [] + for entry in spec.split(','): + entry = entry.strip() + if not entry: + continue + parts = entry.split(':') + if len(parts) != 3: + raise SystemExit( + f"Pin spec entries must be 'name:direction:signal_type'; " + f"got {entry!r}" + ) + name, direction, signal_type = (p.strip() for p in parts) + if direction not in _DIRECTIONS: + raise SystemExit( + f"Pin direction must be one of {sorted(_DIRECTIONS)}; " + f"got {direction!r}" + ) + if signal_type not in _SIGNAL_TYPES: + raise SystemExit( + f"Pin signal_type must be one of {sorted(_SIGNAL_TYPES)}; " + f"got {signal_type!r}" + ) + if not re.match(r'^[a-z][a-z0-9_]*$', name): + raise SystemExit( + f"Pin name must be snake_case starting with a letter; " + f"got {name!r}" + ) + pins.append(PinSpec(name=name, direction=direction, + signal_type=signal_type)) + if not pins: + raise SystemExit("at least one pin must be specified") + return tuple(pins) + + +def _parse_args(argv: list[str]) -> ComponentSpec: + p = argparse.ArgumentParser( + prog='scaffold_component', + description=__doc__.splitlines()[0] if __doc__ else None, + ) + p.add_argument('--name', required=True, + help='CamelCase class name (e.g. MyChip).') + p.add_argument('--kind', required=True, choices=sorted(_KINDS), + help='Component family.') + p.add_argument('--refdes-prefix', required=True, + help='IEEE-315 reference designator prefix (R, U, J, ...).') + p.add_argument('--footprint', required=True, + help='KiCad footprint string (e.g. Package_DIP:DIP-8_W7.62mm).') + p.add_argument('--pins', required=True, + help='Comma-separated pins as `name:direction:signal_type`. ' + 'Direction: in / out / bidir. Signal type: Analog / Digital.') + p.add_argument('--description', required=True, + help='One-line description for the class docstring.') + p.add_argument('--output-root', + help='Repo root override (test harness uses this). Defaults ' + 'to the repo root computed from this script.') + args = p.parse_args(argv) + + if not re.match(r'^[A-Z][A-Za-z0-9_]*$', args.name): + raise SystemExit( + f"--name must be CamelCase starting with an uppercase letter; " + f"got {args.name!r}" + ) + if not re.match(r'^[A-Z]+$', args.refdes_prefix): + raise SystemExit( + f"--refdes-prefix must be one or more uppercase letters " + f"(IEEE 315); got {args.refdes_prefix!r}" + ) + + return ComponentSpec( + class_name=args.name, + kind=args.kind, + refdes_prefix=args.refdes_prefix, + footprint=args.footprint, + pins=_parse_pins(args.pins), + description=args.description, + ) + + +# ----------------------------------------------------------- rendering + + +_DIRECTION_ENUM = { + 'in': 'Direction.IN', + 'out': 'Direction.OUT', + 'bidir': 'Direction.BIDIR', +} + + +def _imports_for(spec: ComponentSpec) -> str: + """Build the imports block. Pulls in only the signal types used + by the pin spec — keeps the generated file tight and matches the + style of the hand-written components.""" + sig_types = sorted({p.signal_type for p in spec.pins}) + signals_import = f"from framework.signals import {', '.join(sig_types)}" + if spec.kind == 'chip': + base_import = "from framework.chip import Chip" + else: + base_import = "from framework.part import Part" + return ( + f"from typing import Any, ClassVar\n" + f"\n" + f"from pydantic import validate_call\n" + f"\n" + f"{base_import}\n" + f"from framework.ground import GroundDomain, ELECTRICAL\n" + f"from framework.port import Port, Direction\n" + f"from framework.refdes import RefdesNumber, validate_refdes\n" + f"{signals_import}\n" + f"from framework.registry import register\n" + ) + + +def _pin_numbers_block(spec: ComponentSpec) -> str: + """`PIN_NUMBERS = {'t1': 1, 't2': 2, ...}` mapping in pin-order.""" + entries = ', '.join(f"'{p.name}': {i}" for i, p in enumerate(spec.pins, 1)) + return f"PIN_NUMBERS: ClassVar[dict[str, int]] = {{{entries}}}" + + +def _ports_block(spec: ComponentSpec) -> str: + """The ports dict, one Port per pin, mandatory=True for IN/OUT/BIDIR + (the scaffolded default — every declared pin matters; the + contributor flips `mandatory=False` per pin if the part legitimately + tolerates a dangling lead).""" + lines = [] + for p in spec.pins: + lines.append( + f" '{p.name}': Port(" + f"'{p.name}', {_DIRECTION_ENUM[p.direction]}, domain, " + f"mandatory=True, signal_type={p.signal_type})," + ) + return '\n'.join(lines) + + +def _has_out(spec: ComponentSpec) -> bool: + return any(p.direction == 'out' for p in spec.pins) + + +def _evaluate_body(spec: ComponentSpec) -> str: + """`evaluate()` body — drive every OUT port with a placeholder so + the scaffold output passes the framework's chip OUT-pin invariant + (a Chip with an OUT pin must drive it). The contributor replaces + the placeholder logic with the real behaviour.""" + out_pins = [p for p in spec.pins if p.direction == 'out'] + if not out_pins: + # No outputs to drive. Passive shapes (resistor-like) have + # this; the body stays a no-op. + return ( + " # No OUT pins to drive — passive part. The\n" + " # framework's `evaluate()` for a passive is a\n" + " # no-op because terminal voltages aren't derivable\n" + " # from each other without solving Ohm's law.\n" + " pass" + ) + drives = [] + for p in out_pins: + if p.signal_type == 'Digital': + drives.append( + f" self._ports['{p.name}'].drive(False) # TODO: real logic" + ) + else: + drives.append( + f" self._ports['{p.name}'].drive(0.0) # TODO: real logic" + ) + return '\n'.join(drives) + + +def _call_signature(spec: ComponentSpec) -> tuple[str, str]: + """Build the `__call__` signature + body using hardware pin names. + + Naming follows CLAUDE.md's rule: pin names, not application-layer + names. The body reads every IN/BIDIR port, drives every OUT port + with a placeholder, then returns `None` (the framework's standard + *callable-but-not-a-calculator* shape — the contributor adapts to + the part's actual signal flow). + """ + in_pins = [p for p in spec.pins if p.direction in ('in', 'bidir')] + out_pins = [p for p in spec.pins if p.direction == 'out'] + + def _annot(p: PinSpec) -> str: + # __call__ accepts the Python primitive at the API surface; + # internal canonicalisation through Port.drive() handles the + # signal_type conversion. + return 'float' if p.signal_type == 'Analog' else 'bool' + + args = ', '.join(f"{p.name}: {_annot(p)}" for p in in_pins) + sig = f" def __call__(self, {args}) -> None:" if args else \ + " def __call__(self) -> None:" + + body_lines = [] + for p in in_pins: + body_lines.append( + f" self._ports['{p.name}'].drive({p.name})" + ) + if out_pins: + body_lines.append(" self.evaluate()") + elif not body_lines: + body_lines.append(" pass") + + return sig, '\n'.join(body_lines) + + +def _class_body(spec: ComponentSpec) -> str: + base = 'Chip' if spec.kind == 'chip' else 'Part' + pin_names_tuple = ', '.join(f"'_{p.name}'" for p in spec.pins) + slots = ( + f" __slots__ = ('_ports', '_refdes_number')" + ) + call_sig, call_body = _call_signature(spec) + return f'''\ +@register('{spec.class_name}') +class {spec.class_name}({base}): + """{spec.description} + + TODO: replace this docstring with the part's real behavioural + description. Include the manufacturer's pin table, the operating + voltage range, and any framework-relevant gotchas the part is + famous for. + """ + +{slots} + + REFDES_PREFIX: ClassVar[str] = '{spec.refdes_prefix}' + FOOTPRINT: ClassVar[str | None] = "{spec.footprint}" + {_pin_numbers_block(spec)} + + LAYOUT: ClassVar[dict[str, Any]] = {{ + # TODO: fill in the layout descriptor — see existing + # components for examples (axial_2lead, dip, qfp, etc.). + }} + + VERIFY: ClassVar[tuple[str, ...]] = ( + # TODO: one or more multimeter / bench-test instructions the + # builder runs *before* powering the board, written as if + # talking the user through it at the bench. + ) + + GOTCHAS: ClassVar[tuple[str, ...]] = ( + # TODO: zero or more assembly-time warnings — the things a + # first-time builder gets wrong about this specific part. + ) + + @validate_call(config={{'arbitrary_types_allowed': True}}) + def __init__( + self, + domain: GroundDomain = ELECTRICAL, + *, + refdes_number: RefdesNumber, + ) -> None: + validate_refdes(self.REFDES_PREFIX, refdes_number) + self._refdes_number = refdes_number + self._ports = {{ +{_ports_block(spec)} + }} + + @property + def ports(self) -> dict[str, Port]: + return self._ports + + @property + def refdes(self) -> str: + return f"{{self.REFDES_PREFIX}}{{self._refdes_number}}" + + @property + def refdes_number(self) -> int: + return self._refdes_number + + def evaluate(self) -> None: +{_evaluate_body(spec)} + +{call_sig} +{call_body} + + def __str__(self) -> str: + return self.refdes + + def __repr__(self) -> str: + return f"{spec.class_name}(refdes={{self.refdes!r}})" +''' + + +def render_component(spec: ComponentSpec) -> str: + """Whole-file render: header comment + imports + class body.""" + return ( + f"# Scaffolded by scripts/scaffold_component.py — fill in the\n" + f"# TODO blocks with this part's real specification.\n" + f"{_imports_for(spec)}\n" + f"\n" + f"{_class_body(spec)}" + ) + + +def render_test_stub(spec: ComponentSpec) -> str: + """A minimal test stub: construction, ports shape, port-direction + and signal-type pinning per the spec. The framework's broader + test pattern (per-pin gotcha tests, per-cell behaviour tests) + becomes the contributor's follow-up; this stub keeps the new + class linted, importable, and refdes-validated.""" + snake = _snake_case(spec.class_name) + module_path = f"components.{spec.kind}s.{snake}" + pin_assertions = [] + for p in spec.pins: + pin_assertions.append( + f" assert part.ports['{p.name}'].direction is " + f"{_DIRECTION_ENUM[p.direction]}" + ) + pin_assertions.append( + f" assert part.ports['{p.name}'].signal_type " + f"is {p.signal_type}" + ) + pin_block = '\n'.join(pin_assertions) + return f'''\ +"""Scaffolded tests for {spec.class_name} — extend as the part grows.""" +from {module_path} import {spec.class_name} +from framework.port import Direction +from framework.signals import Analog, Digital + + +def _make() -> {spec.class_name}: + return {spec.class_name}(refdes_number=1) + + +def test_constructs() -> None: + part = _make() + assert part.refdes == '{spec.refdes_prefix}1' + + +def test_port_shape() -> None: + part = _make() + expected = {set(p.name for p in spec.pins)!r} + assert set(part.ports) == expected + + +def test_port_directions_and_signal_types() -> None: + part = _make() +{pin_block} +''' + + +# --------------------------------------------------------- file output + + +def _component_target_path(spec: ComponentSpec, root: Path) -> Path: + snake = _snake_case(spec.class_name) + return root / 'src' / 'components' / f'{spec.kind}s' / f'{snake}.py' + + +def _test_target_path(spec: ComponentSpec, root: Path) -> Path: + snake = _snake_case(spec.class_name) + return root / 'tests' / 'components' / f'test_{snake}.py' + + +def _init_target_path(spec: ComponentSpec, root: Path) -> Path: + return root / 'src' / 'components' / f'{spec.kind}s' / '__init__.py' + + +def _update_init_all(init_path: Path, class_name: str) -> bool: + """Append `class_name` to the package `__init__.py`'s `__all__` + list and add a matching `from .snake_name import ClassName` line + above. Idempotent — returns False if the name is already + re-exported. + """ + snake = _snake_case(class_name) + text = init_path.read_text() if init_path.exists() else ( + f'"""Auto-generated component module.\n\n' + f'Components live under their kind-specific subpackage; this\n' + f'`__init__.py` re-exports them so callers can do\n' + f'`from components.s import ClassName`.\n' + f'"""\n\n__all__: list[str] = []\n' + ) + if class_name in text: + return False + import_line = f"from .{snake} import {class_name}" + # Insert the import after the last `from . import ...` line, or + # near the top of the module if there are none. + lines = text.splitlines() + last_import_idx = -1 + for i, line in enumerate(lines): + if line.startswith('from .'): + last_import_idx = i + if last_import_idx >= 0: + lines.insert(last_import_idx + 1, import_line) + else: + # Place after the docstring or at top. + insert_at = 0 + if lines and lines[0].startswith('"""'): + # Find end of docstring. + for i in range(1, len(lines)): + if '"""' in lines[i]: + insert_at = i + 1 + break + lines.insert(insert_at, '') + lines.insert(insert_at + 1, import_line) + + # Update __all__. Handles both bracketed-list and append-style. + new_text = '\n'.join(lines) + if re.search(r'__all__\s*=\s*\[', new_text): + new_text = re.sub( + r"(__all__\s*=\s*\[)([^\]]*)\]", + lambda m: _append_to_all_list(m.group(1), m.group(2), class_name), + new_text, + count=1, + ) + else: + new_text = new_text.rstrip() + f"\n\n__all__ = ['{class_name}']\n" + init_path.write_text(new_text + ('\n' if not new_text.endswith('\n') else '')) + return True + + +def _append_to_all_list(prefix: str, body: str, name: str) -> str: + """Append `name` to a bracketed `__all__ = [...]` body, preserving + existing entries and trailing-comma style.""" + stripped = body.strip() + if not stripped: + return f"{prefix}'{name}']" + has_trailing_comma = stripped.endswith(',') + sep = '' if has_trailing_comma else ', ' + new_body = body.rstrip().rstrip(',') + f"{sep} '{name}'," + return f"{prefix}{new_body}]" + + +def write_scaffold(spec: ComponentSpec, root: Path) -> dict[str, Path]: + """Materialise the scaffold under `root`. Returns a dict mapping + each emitted artefact's role (`component`, `test`, `init`) to its + path so the caller (CLI or test) can inspect.""" + component_path = _component_target_path(spec, root) + test_path = _test_target_path(spec, root) + init_path = _init_target_path(spec, root) + + component_path.parent.mkdir(parents=True, exist_ok=True) + test_path.parent.mkdir(parents=True, exist_ok=True) + + if component_path.exists(): + raise SystemExit( + f"Refusing to overwrite existing component file: {component_path}. " + f"Move the file out of the way or pass a different --name." + ) + if test_path.exists(): + raise SystemExit( + f"Refusing to overwrite existing test file: {test_path}. " + f"Move the file out of the way or pass a different --name." + ) + + component_path.write_text(render_component(spec)) + test_path.write_text(render_test_stub(spec)) + _update_init_all(init_path, spec.class_name) + + return { + 'component': component_path, + 'test': test_path, + 'init': init_path, + } + + +def main(argv: list[str] | None = None) -> int: + spec = _parse_args(list(sys.argv[1:] if argv is None else argv)) + args = sys.argv if argv is None else argv + # Allow --output-root to override the inferred repo root. + root_override = None + if argv is None: + # Reparse just to pull --output-root; argparse already validated. + for i, tok in enumerate(args): + if tok == '--output-root' and i + 1 < len(args): + root_override = Path(args[i + 1]) + root = root_override or Path(__file__).resolve().parent.parent + paths = write_scaffold(spec, root) + print(f"Scaffolded:") + for role, path in paths.items(): + print(f" {role}: {path.relative_to(root)}") + print( + "\nNext steps:\n" + f" 1. Open {paths['component'].relative_to(root)} and fill in the TODO\n" + f" blocks (VERIFY / GOTCHAS / pin logic).\n" + f" 2. Open {paths['test'].relative_to(root)} and add behavioural\n" + f" tests beyond the construction shape.\n" + f" 3. Run `uv run pytest {paths['test'].relative_to(root)}` to\n" + f" confirm the scaffolded class still passes after your edits.\n" + ) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/contributor/__init__.py b/tests/contributor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/contributor/_scaffold_harness.py b/tests/contributor/_scaffold_harness.py new file mode 100644 index 0000000..1d1b98a --- /dev/null +++ b/tests/contributor/_scaffold_harness.py @@ -0,0 +1,123 @@ +"""Shared utilities for the scaffold tests. + +Loading the scaffolded files via `importlib.util.spec_from_file_location` +keeps the real `components.passives.*` import namespace untouched — +otherwise a scaffold-generated module would shadow the project's +real components when other tests subsequently `from components.passives +import …`. Each test gets a unique class name to avoid registry +collisions across runs. +""" +from __future__ import annotations + +import importlib.util +import sys +import types +import uuid +from pathlib import Path + +# Import the scaffold module so tests can call into it directly. +# pyproject.toml puts `scripts/` outside `pythonpath`; load it by +# absolute file path. +_SCAFFOLD_PATH = ( + Path(__file__).resolve().parents[2] / 'scripts' / 'scaffold_component.py' +) +_spec = importlib.util.spec_from_file_location( + '_scaffold_component_under_test', _SCAFFOLD_PATH, +) +assert _spec is not None and _spec.loader is not None +scaffold_component = importlib.util.module_from_spec(_spec) +# Register in sys.modules *before* exec — dataclass(@frozen=True) +# inside scaffold_component reads `sys.modules[cls.__module__]` while +# constructing the dataclass, and would crash with AttributeError +# (None.__dict__) otherwise. +sys.modules['_scaffold_component_under_test'] = scaffold_component +_spec.loader.exec_module(scaffold_component) + +# Re-export the public surface so tests can write +# `from ._scaffold_harness import ComponentSpec, …`. +ComponentSpec = scaffold_component.ComponentSpec +PinSpec = scaffold_component.PinSpec +write_scaffold = scaffold_component.write_scaffold +render_component = scaffold_component.render_component +render_test_stub = scaffold_component.render_test_stub +_snake_case = scaffold_component._snake_case + + +def unique_class_name(prefix: str = 'Scaffolded') -> str: + """A CamelCase class name no other test or registry entry will collide with.""" + return f"{prefix}{uuid.uuid4().hex[:10].capitalize()}" + + +def passive_spec(class_name: str | None = None) -> ComponentSpec: + """Canonical two-terminal passive — the shape contributors will + scaffold most often (an LED, a resistor, a thermistor).""" + return ComponentSpec( + class_name=class_name or unique_class_name('PassiveSm'), + kind='passive', + refdes_prefix='R', + footprint='Test_SMD:Test_0603', + pins=( + PinSpec(name='t1', direction='bidir', signal_type='Analog'), + PinSpec(name='t2', direction='bidir', signal_type='Analog'), + ), + description='Scaffolded test passive — used by the contributor suite.', + ) + + +def chip_spec(class_name: str | None = None) -> ComponentSpec: + """A minimal chip shape: power + ground + one digital input + one + digital output. Exercises the OUT-pin code path.""" + return ComponentSpec( + class_name=class_name or unique_class_name('ChipSm'), + kind='chip', + refdes_prefix='U', + footprint='Package_DIP:DIP-4_W7.62mm', + pins=( + PinSpec(name='vcc', direction='in', signal_type='Digital'), + PinSpec(name='gnd', direction='in', signal_type='Digital'), + PinSpec(name='in_', direction='in', signal_type='Digital'), + PinSpec(name='out', direction='out', signal_type='Digital'), + ), + description='Scaffolded test chip — used by the contributor suite.', + ) + + +def load_module_from_path( + module_name: str, + file_path: Path, +) -> types.ModuleType: + """Load a Python file as a module under `module_name`, registering + it in `sys.modules` so subsequent imports referencing that name + resolve to this module (used so the scaffolded test stub's + `from components.s. import ` finds the loaded + scaffold component instead of failing because the path was never + on `sys.path`).""" + spec = importlib.util.spec_from_file_location(module_name, file_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Cannot load {file_path} as {module_name}") + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + + +def materialise_and_load( + spec: ComponentSpec, root: Path, +) -> tuple[dict[str, Path], types.ModuleType, types.ModuleType]: + """Run the scaffold under `root`, then load both the generated + component and its test stub, exposing them as live modules ready + for assertion or test-function invocation. + + Returns (paths, component_module, test_module). + """ + paths = write_scaffold(spec, root) + snake = _snake_case(spec.class_name) + component_module_name = f'components.{spec.kind}s.{snake}' + component_module = load_module_from_path( + component_module_name, paths['component'], + ) + test_module_name = ( + f'_scaffold_test_stub.{spec.kind}.{snake}_{uuid.uuid4().hex[:6]}' + ) + test_module = load_module_from_path(test_module_name, paths['test']) + return paths, component_module, test_module diff --git a/tests/contributor/test_scaffold_emits_tests.py b/tests/contributor/test_scaffold_emits_tests.py new file mode 100644 index 0000000..f982ccb --- /dev/null +++ b/tests/contributor/test_scaffold_emits_tests.py @@ -0,0 +1,82 @@ +"""The scaffold's test stub must pass against the scaffold's own class +output — *no manual edit needed* to make the construction / +port-shape / signal-type assertions pass. + +We invoke the generated test functions directly rather than spawning +a pytest subprocess: the stub's tests are simple `def test_*()` shapes +with no fixtures, so calling them in-process is faster and equivalent. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +from ._scaffold_harness import ( + chip_spec, + materialise_and_load, + passive_spec, +) + + +def _run_every_test(test_module) -> int: + """Call each `test_*` function in `test_module` and count how many + ran. Any failed assertion bubbles up as a `pytest.fail`-equivalent + AssertionError, surfacing in the surrounding test's report.""" + count = 0 + for name in sorted(dir(test_module)): + if not name.startswith('test_'): + continue + fn = getattr(test_module, name) + if not callable(fn): + continue + fn() + count += 1 + return count + + +def test_passive_scaffold_test_stub_passes(tmp_path: Path) -> None: + """The two-terminal passive's scaffolded test stub must pass + against the scaffolded class — the regression boundary for + *the boilerplate is correct by construction*.""" + spec = passive_spec() + _, _, test_module = materialise_and_load(spec, tmp_path) + ran = _run_every_test(test_module) + assert ran >= 3, ( + f"Expected at least construction, port_shape, and " + f"port_directions tests; only {ran} ran." + ) + + +def test_chip_scaffold_test_stub_passes(tmp_path: Path) -> None: + """The chip-with-OUT-pin scaffolded test stub must also pass + without contributor edits.""" + spec = chip_spec() + _, _, test_module = materialise_and_load(spec, tmp_path) + ran = _run_every_test(test_module) + assert ran >= 3 + + +@pytest.mark.parametrize( + 'spec_factory', + [passive_spec, chip_spec], + ids=['passive', 'chip'], +) +def test_test_stub_asserts_each_pin( + tmp_path: Path, spec_factory, +) -> None: + """Every declared pin gets a direction + signal_type assertion in + the stub — the contributor sees one line per pin so renaming or + redirecting a pin doesn't go silently.""" + spec = spec_factory() + paths, _, _ = materialise_and_load(spec, tmp_path) + text = paths['test'].read_text() + for pin in spec.pins: + assert f"part.ports['{pin.name}'].direction" in text, ( + f"Test stub missing direction assertion for pin " + f"{pin.name!r}." + ) + assert f"part.ports['{pin.name}'].signal_type" in text, ( + f"Test stub missing signal_type assertion for pin " + f"{pin.name!r}." + ) diff --git a/tests/contributor/test_scaffold_output.py b/tests/contributor/test_scaffold_output.py new file mode 100644 index 0000000..6816d96 --- /dev/null +++ b/tests/contributor/test_scaffold_output.py @@ -0,0 +1,153 @@ +"""Static + import-time checks on the scaffold's component output. + +A scaffolded component file must satisfy *every framework rule by +construction* — no manual edit required to make `__slots__`, the +required ClassVars, the port shape, the registry decoration, or the +refdes-bearing surface conform. These tests are the regression +boundary: if a future refactor of the scaffold drops a ClassVar or +forgets `__slots__`, the breach is caught here. +""" +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest + +from ._scaffold_harness import ( + chip_spec, + materialise_and_load, + passive_spec, + render_component, + unique_class_name, +) + + +REQUIRED_CLASSVARS = ( + 'REFDES_PREFIX', + 'FOOTPRINT', + 'PIN_NUMBERS', + 'LAYOUT', + 'VERIFY', + 'GOTCHAS', +) + + +# ============================================================ passive + + +def test_passive_scaffold_renders_parseable_python(tmp_path: Path) -> None: + """`render_component` must produce valid Python — no syntax errors, + no malformed f-strings. Catches template-string regressions + without running pytest in a subprocess.""" + text = render_component(passive_spec(unique_class_name('PassiveParse'))) + ast.parse(text) + + +def test_passive_scaffold_declares_slots(tmp_path: Path) -> None: + paths, component_module, _ = materialise_and_load( + passive_spec(), tmp_path, + ) + text = paths['component'].read_text() + assert "__slots__ = (" in text, ( + "Scaffolded component must declare __slots__ — physical " + "components cannot grow attributes at runtime." + ) + + +@pytest.mark.parametrize('classvar', REQUIRED_CLASSVARS) +def test_passive_scaffold_declares_all_required_classvars( + classvar: str, tmp_path: Path, +) -> None: + """Every component must surface the six ClassVars the framework + reads downstream (refdes prefix, footprint, pin numbers, layout, + verify, gotchas) — the scaffold can't omit any.""" + paths, component_module, _ = materialise_and_load( + passive_spec(), tmp_path, + ) + text = paths['component'].read_text() + assert f"{classvar}:" in text or f"{classvar} =" in text, ( + f"Scaffolded component is missing required ClassVar " + f"{classvar!r}." + ) + + +def test_passive_scaffold_constructs_cleanly(tmp_path: Path) -> None: + """Loading and instantiating the scaffolded class must succeed + without manual edits — the framework's construction-time invariants + pass by default.""" + spec = passive_spec() + paths, component_module, _ = materialise_and_load(spec, tmp_path) + cls = getattr(component_module, spec.class_name) + instance = cls(refdes_number=1) + assert instance.refdes == f"{spec.refdes_prefix}1" + assert set(instance.ports) == {p.name for p in spec.pins} + + +def test_passive_scaffold_registers_with_framework_registry( + tmp_path: Path, +) -> None: + """The scaffold emits `@register('ClassName')` so the class shows up + in the framework registry — required for `.wirebench` round-trip.""" + from framework.registry import is_registered + spec = passive_spec() + _, component_module, _ = materialise_and_load(spec, tmp_path) + cls = getattr(component_module, spec.class_name) + assert is_registered(cls), ( + f"{spec.class_name} did not land in the framework registry — " + "`@register(...)` decoration is missing or broken." + ) + + +def test_passive_scaffold_updates_init_all(tmp_path: Path) -> None: + """Re-export in the kind subpackage `__init__.py` so callers can + do `from components.passives import ClassName`.""" + spec = passive_spec() + paths, _, _ = materialise_and_load(spec, tmp_path) + init_text = paths['init'].read_text() + assert spec.class_name in init_text, ( + "Scaffolded class is not re-exported from the kind's " + "__init__.py — `from components.passives import …` won't see " + "it without manual editing." + ) + + +# =============================================================== chip + + +def test_chip_scaffold_constructs_cleanly(tmp_path: Path) -> None: + """A scaffolded chip with an OUT pin still constructs — `evaluate()` + drives every OUT pin with a placeholder so the framework's + OUT-pin-must-be-driven invariant passes by default.""" + spec = chip_spec() + paths, component_module, _ = materialise_and_load(spec, tmp_path) + cls = getattr(component_module, spec.class_name) + instance = cls(refdes_number=1) + assert instance.refdes == f"{spec.refdes_prefix}1" + # OUT pin is driven by evaluate() — exercise that path too. + instance.evaluate() + assert instance.ports['out'].value is False # placeholder for Digital OUT + + +def test_chip_scaffold_declares_chip_as_base(tmp_path: Path) -> None: + """The chip kind inherits from `Chip`; the passive kind from + `Part`. Picking the right base class is the friction the scaffold + machine-applies.""" + paths, component_module, _ = materialise_and_load(chip_spec(), tmp_path) + text = paths['component'].read_text() + assert 'from framework.chip import Chip' in text + assert '(Chip)' in text + + +# =================================================== refused overwrite + + +def test_scaffold_refuses_to_overwrite_existing_component( + tmp_path: Path, +) -> None: + """A second scaffold call against the same name raises rather than + silently clobbering existing work.""" + spec = passive_spec(unique_class_name('OverwriteGuard')) + materialise_and_load(spec, tmp_path) + with pytest.raises(SystemExit, match='Refusing to overwrite'): + materialise_and_load(spec, tmp_path) From b564b637fbcf2c5ac8195b59189effccdac8f4bf Mon Sep 17 00:00:00 2001 From: subzero Date: Wed, 20 May 2026 20:30:48 +0200 Subject: [PATCH 2/2] review: fix three bugs in the scaffold (High: chip invariants, Medium: acronym filenames, Low: phantom --interactive) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **High — chip scaffold produced invalid Chip subclasses.** The previous template subclassed `Chip` but only set `_refdes_number` and `_ports` and never called `Chip.__init__`. `Chip.__init__` builds the port map from `Pin` objects, asserts every OUT pin is internally driven by a cell, and validates the drive-type declarations — none of which the scaffold's output supported. Worse, `Chip.__call__` is abstract; the scaffold's class couldn't instantiate at all (`TypeError: abstract method __call__`). Rewritten chip template: - Constructs one `Pin(PinId(n, name), …)` per declared pin. - For each OUT pin, instantiates an `IdleDriver` placeholder cell and wires `cell.ports['out']` to `pin.internal` so the *every OUT pin is internally driven* invariant passes by construction. Contributor replaces each `IdleDriver` with the real behavioural cell (concept cells under `src/components/chips/concepts/`). - Calls `super().__init__(pins=[…], cells=[…])` so the framework's port map, ERC checks, and refdes uniqueness pass cleanly. - Emits a concrete `__call__` mirroring `SN74HC04.__call__` — drives every IN/BIDIR pin, runs `evaluate()`, returns the tuple of OUT-pin values. `tests/contributor/test_scaffold_output.py` now has explicit assertions that: - The chip class constructs without `PartConfigurationError`. - Driving the chip via `__call__()` settles the OUT pin at the `IdleDriver`'s idle value (proves the cell is actually wired). - `super().__init__(` appears in the chip output. **Medium — `_snake_case` produced wrong filenames for acronyms.** The previous regex `(? str: - # CamelCase → snake_case; handles consecutive caps (LM7805 → lm7805). - s = re.sub(r'(? ComponentSpec: def _imports_for(spec: ComponentSpec) -> str: """Build the imports block. Pulls in only the signal types used by the pin spec — keeps the generated file tight and matches the - style of the hand-written components.""" + style of the hand-written components. + + Chip scaffolds get the additional surface they need to satisfy + `Chip.__init__`: `Pin`, `PinId`, `wire`, and `IdleDriver` as the + placeholder cell that drives every OUT pin's internal face.""" sig_types = sorted({p.signal_type for p in spec.pins}) signals_import = f"from framework.signals import {', '.join(sig_types)}" if spec.kind == 'chip': - base_import = "from framework.chip import Chip" - else: - base_import = "from framework.part import Part" + return ( + f"from typing import Any, ClassVar\n" + f"\n" + f"from pydantic import validate_call\n" + f"\n" + f"from framework.chip import Chip\n" + f"from framework.ground import GroundDomain, ELECTRICAL\n" + f"from framework.pin import Pin, PinId\n" + f"from framework.port import Direction\n" + f"from framework.refdes import RefdesNumber, validate_refdes\n" + f"from framework.registry import register\n" + f"{signals_import}\n" + f"from framework.wire import wire\n" + f"\n" + f"from .concepts.idle_driver import IdleDriver\n" + ) return ( f"from typing import Any, ClassVar\n" f"\n" f"from pydantic import validate_call\n" f"\n" - f"{base_import}\n" + f"from framework.part import Part\n" f"from framework.ground import GroundDomain, ELECTRICAL\n" f"from framework.port import Port, Direction\n" f"from framework.refdes import RefdesNumber, validate_refdes\n" @@ -287,25 +322,16 @@ def _annot(p: PinSpec) -> str: def _class_body(spec: ComponentSpec) -> str: - base = 'Chip' if spec.kind == 'chip' else 'Part' - pin_names_tuple = ', '.join(f"'_{p.name}'" for p in spec.pins) - slots = ( - f" __slots__ = ('_ports', '_refdes_number')" + return ( + _chip_class_body(spec) if spec.kind == 'chip' + else _passive_class_body(spec) ) - call_sig, call_body = _call_signature(spec) - return f'''\ -@register('{spec.class_name}') -class {spec.class_name}({base}): - """{spec.description} - TODO: replace this docstring with the part's real behavioural - description. Include the manufacturer's pin table, the operating - voltage range, and any framework-relevant gotchas the part is - famous for. - """ - -{slots} +def _shared_classvars(spec: ComponentSpec) -> str: + """The six ClassVars every component class declares. Identical + across kinds — only the surrounding init / port machinery differs.""" + return f'''\ REFDES_PREFIX: ClassVar[str] = '{spec.refdes_prefix}' FOOTPRINT: ClassVar[str | None] = "{spec.footprint}" {_pin_numbers_block(spec)} @@ -325,7 +351,25 @@ class {spec.class_name}({base}): # TODO: zero or more assembly-time warnings — the things a # first-time builder gets wrong about this specific part. ) +''' + + +def _passive_class_body(spec: ComponentSpec) -> str: + call_sig, call_body = _call_signature(spec) + return f'''\ +@register('{spec.class_name}') +class {spec.class_name}(Part): + """{spec.description} + + TODO: replace this docstring with the part's real behavioural + description. Include the manufacturer's pin table, the operating + voltage range, and any framework-relevant gotchas the part is + famous for. + """ + __slots__ = ('_ports', '_refdes_number') + +{_shared_classvars(spec)} @validate_call(config={{'arbitrary_types_allowed': True}}) def __init__( self, @@ -365,6 +409,157 @@ def __repr__(self) -> str: ''' +def _chip_pin_construction(spec: ComponentSpec) -> str: + """Build a Pin per declared port — the `Pin(PinId(n, 'name'), ...)` + shape every concrete Chip uses.""" + lines = [] + for i, p in enumerate(spec.pins, 1): + mandatory = 'True' if p.direction != 'out' else 'False' + lines.append( + f" pin_{p.name} = Pin(\n" + f" PinId({i}, '{p.name}'),\n" + f" {_DIRECTION_ENUM[p.direction]},\n" + f" domain,\n" + f" mandatory={mandatory},\n" + f" signal_type={p.signal_type},\n" + f" )" + ) + return '\n'.join(lines) + + +def _chip_cell_wiring(spec: ComponentSpec) -> tuple[str, str]: + """For each OUT pin, instantiate an `IdleDriver` and wire its + `out` port to `pin.internal`. Satisfies the framework's "every + OUT pin is internally driven" invariant — the scaffolded chip + constructs without `BARE_FIRMWARE_DRIVEN`, and the contributor + later replaces each `IdleDriver` with the real behavioural cell. + + Returns `(cells_decl, cells_list)` where `cells_decl` is the + statements that build the cells and `cells_list` is the + comma-joined expression for `super().__init__(cells=...)`. + """ + out_pins = [p for p in spec.pins if p.direction == 'out'] + if not out_pins: + return ('', '') + decl_lines: list[str] = [] + cell_names: list[str] = [] + for p in out_pins: + idle_val = 'False' if p.signal_type == 'Digital' else '0.0' + cell_name = f"cell_{p.name}" + decl_lines.append( + f" {cell_name} = IdleDriver(" + f"{p.signal_type}, idle_value={idle_val}, domain=domain)" + ) + decl_lines.append( + f" wire({cell_name}.ports['out'], pin_{p.name}.internal)" + ) + cell_names.append(cell_name) + return ('\n'.join(decl_lines), ', '.join(cell_names)) + + +def _chip_call_signature_and_body(spec: ComponentSpec) -> tuple[str, str]: + """Build a concrete `__call__` for the chip scaffold — `Chip.__call__` + is abstract, so every concrete Chip subclass must implement one. + The scaffold's shape mirrors `SN74HC04.__call__`: take each IN / + BIDIR pin as a keyword argument with a sensible default, drive + the corresponding external port, run `evaluate()`, return the + tuple of OUT-pin values.""" + in_pins = [p for p in spec.pins if p.direction in ('in', 'bidir')] + out_pins = [p for p in spec.pins if p.direction == 'out'] + + def _annot(p: PinSpec) -> str: + return 'float | None' if p.signal_type == 'Analog' else 'bool | None' + + def _default(p: PinSpec) -> str: + return '0.0' if p.signal_type == 'Analog' else 'False' + + if not in_pins: + sig = " def __call__(self) -> Any:" + else: + # Format on continuation lines for readability when many pins. + args = ',\n '.join( + f"{p.name}: {_annot(p)} = {_default(p)}" for p in in_pins + ) + sig = f" def __call__(\n self,\n {args},\n ) -> Any:" + + body_lines = [" self._assert_no_inputs_wired()"] + for p in in_pins: + body_lines.append(f" self._ports['{p.name}'].drive({p.name})") + body_lines.append(" self.evaluate()") + if out_pins: + return_tuple = ', '.join(f"self._ports['{p.name}'].value" for p in out_pins) + body_lines.append(f" return ({return_tuple},)") + else: + body_lines.append(" return None") + return sig, '\n'.join(body_lines) + + +def _chip_class_body(spec: ComponentSpec) -> str: + pin_var_list = ', '.join(f"pin_{p.name}" for p in spec.pins) + cell_decl, cell_list = _chip_cell_wiring(spec) + cell_decl_block = ('\n' + cell_decl) if cell_decl else '' + cell_arg = f"[{cell_list}]" if cell_list else '[]' + call_sig, call_body = _chip_call_signature_and_body(spec) + # `__slots__` for a Chip is empty by convention — Chip's own + # `__slots__` carries the framework-needed members. The scaffold + # adds `_refdes_number` (chip-specific) and leaves room for the + # contributor to add more if the part needs internal state. + return f'''\ +@register('{spec.class_name}') +class {spec.class_name}(Chip): + """{spec.description} + + TODO: replace this docstring with the part's real behavioural + description. Include the manufacturer's pin table, the operating + voltage range, and any framework-relevant gotchas the part is + famous for. + + The scaffold wires every OUT pin to an `IdleDriver` cell as a + placeholder so the framework's *every OUT pin is internally + driven* invariant passes by construction. Replace each + `IdleDriver` with the real behavioural cell once you know the + part's logic (concept cells live in + `src/components/chips/concepts/`). + """ + + __slots__ = ('_refdes_number',) + +{_shared_classvars(spec)} + @validate_call(config={{'arbitrary_types_allowed': True}}) + def __init__( + self, + domain: GroundDomain = ELECTRICAL, + *, + refdes_number: RefdesNumber, + ) -> None: + validate_refdes(self.REFDES_PREFIX, refdes_number) + self._refdes_number = refdes_number +{_chip_pin_construction(spec)}{cell_decl_block} + super().__init__( + pins=[{pin_var_list}], + cells={cell_arg}, + ) + + @property + def refdes(self) -> str: + return f"{{self.REFDES_PREFIX}}{{self._refdes_number}}" + + @property + def refdes_number(self) -> int: + return self._refdes_number + + @validate_call(config={{'arbitrary_types_allowed': True}}) +{call_sig} +{call_body} + + def __str__(self) -> str: + return self.refdes + + def __repr__(self) -> str: + return f"{spec.class_name}(refdes={{self.refdes!r}})" +''' + + def render_component(spec: ComponentSpec) -> str: """Whole-file render: header comment + imports + class body.""" return ( diff --git a/tests/contributor/_scaffold_harness.py b/tests/contributor/_scaffold_harness.py index 1d1b98a..b304e17 100644 --- a/tests/contributor/_scaffold_harness.py +++ b/tests/contributor/_scaffold_harness.py @@ -66,15 +66,20 @@ def passive_spec(class_name: str | None = None) -> ComponentSpec: def chip_spec(class_name: str | None = None) -> ComponentSpec: """A minimal chip shape: power + ground + one digital input + one - digital output. Exercises the OUT-pin code path.""" + digital output. Exercises the OUT-pin code path. + + Supply pins (`vcc`, `gnd`) are declared `Analog` because the + framework's name-based pin-function inference classifies any + `VCC` / `VDD` / `GND` / `VSS` (case-insensitive) as POWER / + GROUND, and those functions require an `Analog` signal_type.""" return ComponentSpec( class_name=class_name or unique_class_name('ChipSm'), kind='chip', refdes_prefix='U', footprint='Package_DIP:DIP-4_W7.62mm', pins=( - PinSpec(name='vcc', direction='in', signal_type='Digital'), - PinSpec(name='gnd', direction='in', signal_type='Digital'), + PinSpec(name='vcc', direction='in', signal_type='Analog'), + PinSpec(name='gnd', direction='in', signal_type='Analog'), PinSpec(name='in_', direction='in', signal_type='Digital'), PinSpec(name='out', direction='out', signal_type='Digital'), ), diff --git a/tests/contributor/test_scaffold_output.py b/tests/contributor/test_scaffold_output.py index 6816d96..7a0ff3b 100644 --- a/tests/contributor/test_scaffold_output.py +++ b/tests/contributor/test_scaffold_output.py @@ -116,17 +116,50 @@ def test_passive_scaffold_updates_init_all(tmp_path: Path) -> None: def test_chip_scaffold_constructs_cleanly(tmp_path: Path) -> None: - """A scaffolded chip with an OUT pin still constructs — `evaluate()` - drives every OUT pin with a placeholder so the framework's - OUT-pin-must-be-driven invariant passes by default.""" + """A scaffolded chip must construct without `PartConfigurationError` + — `Chip.__init__` enforces *every OUT pin is internally driven* + by a cell, and the scaffold satisfies that by wiring an + `IdleDriver` to each OUT pin's internal face.""" spec = chip_spec() paths, component_module, _ = materialise_and_load(spec, tmp_path) cls = getattr(component_module, spec.class_name) instance = cls(refdes_number=1) assert instance.refdes == f"{spec.refdes_prefix}1" - # OUT pin is driven by evaluate() — exercise that path too. - instance.evaluate() - assert instance.ports['out'].value is False # placeholder for Digital OUT + # Every declared external pin surfaces on the chip's ports map. + for pin in spec.pins: + assert pin.name in instance.ports + + +def test_chip_scaffold_drives_every_out_pin_via_a_cell( + tmp_path: Path, +) -> None: + """`Chip._assert_every_out_pin_is_internally_driven` would refuse + the scaffold's output if any OUT pin's internal face had no + cell-side driver. The scaffold passes this check by construction + via an `IdleDriver` per OUT pin.""" + spec = chip_spec() + _, component_module, _ = materialise_and_load(spec, tmp_path) + cls = getattr(component_module, spec.class_name) + # Construction would have raised PartConfigurationError if the + # invariant didn't hold. + instance = cls(refdes_number=1) + # Driving the chip via __call__ + evaluate() should leave the OUT + # pin at the IdleDriver's idle value (False for Digital). + instance(in_=True) + assert instance.ports['out'].value is False + + +def test_chip_scaffold_implements_concrete_call(tmp_path: Path) -> None: + """`Chip.__call__` is abstract; a scaffold that didn't override it + would fail at `cls(refdes_number=1)` with TypeError. The chip + template emits a concrete `__call__` that drives every IN/BIDIR + pin from its argument.""" + spec = chip_spec() + paths, component_module, _ = materialise_and_load(spec, tmp_path) + text = paths['component'].read_text() + assert "def __call__(" in text + # The signature must mention the IN pin name (concrete, not abstract). + assert "in_:" in text def test_chip_scaffold_declares_chip_as_base(tmp_path: Path) -> None: @@ -137,6 +170,36 @@ def test_chip_scaffold_declares_chip_as_base(tmp_path: Path) -> None: text = paths['component'].read_text() assert 'from framework.chip import Chip' in text assert '(Chip)' in text + assert 'super().__init__(' in text, ( + "Chip scaffold must call `super().__init__(pins=..., cells=...)` " + "so `Chip.__init__` builds the port map and validates the " + "OUT-pin invariant." + ) + + +# =============================================== name → filename mapping + + +@pytest.mark.parametrize( + 'class_name,expected_filename', + [ + ('LM7806', 'lm7806'), + ('SN74HC04', 'sn74hc04'), + ('ATmega328P', 'atmega328p'), + ('MyChip', 'my_chip'), + ('HTTPServer', 'http_server'), + ], +) +def test_class_name_to_filename_handles_acronyms( + class_name: str, expected_filename: str, +) -> None: + """Acronym-heavy part names map to the conventional Python + file-naming style: `LM7806` → `lm7806.py`, not `l_m7806.py`; + `SN74HC04` → `sn74hc04.py`, not `s_n74_h_c04.py`. The wirebench + catalogue's existing file naming is the reference (see + `src/components/chips/atmega328p.py`, `sn74hc04.py`, etc.).""" + from ._scaffold_harness import _snake_case + assert _snake_case(class_name) == expected_filename # =================================================== refused overwrite