diff --git a/src/cli/validate.py b/src/cli/validate.py index 251e414..6101cd5 100644 --- a/src/cli/validate.py +++ b/src/cli/validate.py @@ -93,6 +93,20 @@ def _emit(payload: dict[str, Any]) -> None: sys.stdout.write('\n') +def _details_with_remediation(e: WirebenchError) -> dict[str, Any]: + """Build the `details` dict for a framework exception, merging the + structured fields the regex extractor scrapes from the message with + the high-confidence remediation hint the exception class produces + (when one applies). Omits the `remediation` key entirely when the + class returned `None` — keeps the JSON shape minimal for the + common low-confidence case.""" + details: dict[str, Any] = dict(extract(type(e).__name__, str(e))) + remediation = e.suggested_remediation() + if remediation is not None: + details['remediation'] = remediation + return details + + def _error(message: str, error_class: str, *, design: str | None = None) -> int: _emit({ 'status': 'error', @@ -216,7 +230,7 @@ def run_validate(argv: list[str]) -> int: 'design': target.__name__, 'error_class': type(e).__name__, 'message': str(e), - 'details': extract(type(e).__name__, str(e)), + 'details': _details_with_remediation(e), }) return 1 except (ValueError, TypeError) as e: diff --git a/src/components/chips/concepts/nor_latch.py b/src/components/chips/concepts/nor_latch.py index 451430d..2c18ff9 100644 --- a/src/components/chips/concepts/nor_latch.py +++ b/src/components/chips/concepts/nor_latch.py @@ -46,7 +46,11 @@ def evaluate(self) -> None: s = bool(Digital(self._ports['s'].value)) r = bool(Digital(self._ports['r'].value)) if s and r: - raise ForbiddenStateError("Invalid: S and R both active") + raise ForbiddenStateError( + "Invalid: S and R both active", + state_signature='sr_latch_both_active', + port_names=('s', 'r'), + ) if s: self._q = True elif r: diff --git a/src/framework/circuit.py b/src/framework/circuit.py index 3478e6f..a19e56b 100644 --- a/src/framework/circuit.py +++ b/src/framework/circuit.py @@ -210,7 +210,10 @@ def _validate(self, parts: list[Part]) -> None: ] if unconnected: raise UnconnectedPinError( - f"Unconnected mandatory port(s): {', '.join(unconnected)}" + f"Unconnected mandatory port(s): {', '.join(unconnected)}", + port_refs=tuple( + ref.strip("'") for ref in unconnected + ), ) # Net-aware short-circuit / floating detection. Delegated to @@ -222,8 +225,10 @@ def _validate(self, parts: list[Part]) -> None: from framework.export.nets import compute_logical_nets shorts: list[str] = [] short_locations: list[tuple[str, int]] = [] + short_drivers: list[str] = [] floats: list[str] = [] float_locations: list[tuple[str, int]] = [] + float_port_refs: list[str] = [] for net in compute_logical_nets(self): outs = [(o, p) for (o, p) in net.ports if p.direction is Direction.OUT] bidirs = [(o, p) for (o, p) in net.ports if p.direction is Direction.BIDIR] @@ -231,6 +236,8 @@ def _validate(self, parts: list[Part]) -> None: if len(outs) > 1: shorts.append(', '.join( f"'{type(o).__name__}.{p.name}'" for o, p in outs)) + for o, p in outs: + short_drivers.append(f"{type(o).__name__}.{p.name}") for loc in self._collect_net_source_locations(net.ports): if loc not in short_locations: short_locations.append(loc) @@ -243,20 +250,32 @@ def _validate(self, parts: list[Part]) -> None: # detection above stays strict regardless. floats.append(', '.join( f"'{type(o).__name__}.{p.name}'" for o, p in bidirs)) + for o, p in bidirs: + float_port_refs.append(f"{type(o).__name__}.{p.name}") for loc in self._collect_net_source_locations(net.ports): if loc not in float_locations: float_locations.append(loc) if shorts: + # Carry the structured driver list *only when one net is + # shorted* — multi-net diagnostics would conflate drivers + # from independent shorts, and the remediation only fires + # at the two-driver canonical shape anyway. + drivers: tuple[str, ...] = ( + tuple(short_drivers) if len(shorts) == 1 else () + ) raise ShortCircuitError( "Short circuit on logical net — multiple drivers: " + '; '.join(shorts), + drivers=drivers, source_locations=short_locations, ) if floats: raise FloatingNetError( "Floating logical net — multiple passive BIDIRs with no driver: " + '; '.join(floats), + kind='multi_bidir', + port_refs=tuple(float_port_refs), source_locations=float_locations, ) @@ -275,8 +294,21 @@ def _validate(self, parts: list[Part]) -> None: else: seen[key] = label if collisions: + # Single canonical duplicate → carry the refdes string so + # the remediation can name it. Multi-collision diagnostics + # are common enough that a generic suggestion would still + # apply, but the framework names only the specific case it + # can be confident about. + duplicate_refdes = '' + if len(collisions) == 1: + # `collisions[0]` is "'X.Rn' and 'Y.Rn'" — extract the + # shared refdes from the right-hand label. + tail = collisions[0].rsplit('.', 1)[-1] + duplicate_refdes = tail.rstrip("'") raise RefdesError( - f"Duplicate refdes: {'; '.join(collisions)}" + f"Duplicate refdes: {'; '.join(collisions)}", + kind='duplicate', + duplicate_refdes=duplicate_refdes, ) @property diff --git a/src/framework/errors.py b/src/framework/errors.py index 8dfe1cc..bb6807d 100644 --- a/src/framework/errors.py +++ b/src/framework/errors.py @@ -63,6 +63,23 @@ def source_locations(self) -> tuple[tuple[str, int], ...]: outside the `wire()` path).""" return self._source_locations + def suggested_remediation(self) -> str | None: + """Return a high-confidence remediation hint, or `None` if the + defect's resolution depends on design intent the framework + can't infer. + + Subclasses override to provide per-defect-class suggestions. + Default returns `None`, the *silent when confidence is low* + posture: the framework names the defect and explains why + without pretending to know which fix the designer intended. + + Remediation strings are *teaching-toned* — they explain what + to do and offer the alternative when more than one fix is + valid, rather than imperative "do X now." They never suggest + bypassing a framework check or silencing the validation. + """ + return None + def __str__(self) -> str: base = super().__str__() parts = [base] @@ -75,6 +92,9 @@ def __str__(self) -> str: for filename, lineno in self._source_locations ) parts.append(f" Wired at: {locs}") + remediation = self.suggested_remediation() + if remediation: + parts.append(f" Try: {remediation}") return "\n".join(parts) @@ -115,6 +135,29 @@ class ShortCircuitError(WiringError, ValueError): "the FETs overheat; one driver per shared conductor." ) + def __init__( + self, + *args: Any, + drivers: Sequence[str] = (), + source_locations: Sequence[tuple[str, int]] | None = None, + ) -> None: + super().__init__(*args, source_locations=source_locations) + self.drivers: tuple[str, ...] = tuple(drivers) + + def suggested_remediation(self) -> str | None: + # High confidence only for the two-driver canonical case; + # three-or-more shorts could be unintended fan-out, a missing + # tri-state enable, or a deliberate wired-OR that should have + # been built differently — no single fix dominates. + if len(self.drivers) == 2: + a, b = self.drivers + return ( + f"Remove one of the two wire() calls connecting " + f"{a} and {b}, OR insert a series element (resistor, " + f"diode) between them to break the direct conflict." + ) + return None + class FloatingNetError(WiringError, ValueError): """A net touched only by passive BIDIR ports with no driver @@ -126,6 +169,35 @@ class FloatingNetError(WiringError, ValueError): "switching noise as if the trace were an antenna." ) + def __init__( + self, + *args: Any, + kind: str = '', + port_refs: Sequence[str] = (), + source_locations: Sequence[tuple[str, int]] | None = None, + ) -> None: + super().__init__(*args, source_locations=source_locations) + # `kind`: + # 'multi_bidir' — net has only passive BIDIRs, validate-time + # 'all_in' — wire-time refusal when every port is IN + # Suggestions diverge between the two: the multi-BIDIR case has + # a high-confidence pair of fixes; the all-IN case typically + # means the user forgot a driver entirely, which is design- + # intent territory. + self.kind: str = kind + self.port_refs: tuple[str, ...] = tuple(port_refs) + + def suggested_remediation(self) -> str | None: + if self.kind == 'multi_bidir': + return ( + "Wire one port to a Rail (or to an OUT-direction port) so " + "something defines the net's value, OR pass " + "`dynamically_driven=True` to `wire()` if this is an " + "analog feedback node driven through the surrounding " + "loop (op-amp bias divider, RC timing network)." + ) + return None + class UnconnectedPinError(WiringError, ValueError): """A pin declared `mandatory=True` was not connected to any wire @@ -137,6 +209,25 @@ class UnconnectedPinError(WiringError, ValueError): "doesn't power up or behaves unpredictably." ) + def __init__( + self, + *args: Any, + port_refs: Sequence[str] = (), + source_locations: Sequence[tuple[str, int]] | None = None, + ) -> None: + super().__init__(*args, source_locations=source_locations) + self.port_refs: tuple[str, ...] = tuple(port_refs) + + def suggested_remediation(self) -> str | None: + if len(self.port_refs) == 1: + ref = self.port_refs[0] + return ( + f"Wire {ref} to the supply or signal source it " + f"requires — every mandatory pin must be driven inside " + f"the enclosing circuit." + ) + return None + class NodeMergeError(WiringError, ValueError): """`wire()` was asked to join ports already on two distinct @@ -181,6 +272,40 @@ class SignalTypeMismatchError(SignalError, TypeError): "explicit interface (comparator, ADC, level-shifter)." ) + def __init__( + self, + *args: Any, + port_types: Sequence[tuple[str, str]] = (), + source_locations: Sequence[tuple[str, int]] | None = None, + ) -> None: + super().__init__(*args, source_locations=source_locations) + # Pairs of (port_name, signal_type_class_name) — the framework + # knows the participating types but not the designer's intent + # for the interface; the suggestion lists the canonical + # conversion elements without prescribing one. + self.port_types: tuple[tuple[str, str], ...] = tuple(port_types) + + def suggested_remediation(self) -> str | None: + # Two paths raise this exception: (a) `wire()` joining ports + # with mismatched signal_types, where Analog↔Digital is the + # canonical case the suggestion below addresses; and (b) + # `Port.drive()` failing to coerce a runtime value to the + # port's signal_type (e.g. a string onto a numeric port). The + # conversion-element advice fits (a) but not (b) — for (b) the + # fix is to supply a correctly-typed value, which depends on + # design intent the framework can't infer. Gate on the + # canonical shape: both 'Analog' and 'Digital' must be among + # the port_types. + types = {t for _, t in self.port_types} + if 'Analog' in types and 'Digital' in types: + return ( + "Insert a comparator, ADC, or level-shifter between the " + "Analog and Digital ports — they can't share copper " + "directly because one carries a continuous voltage and " + "the other a logic level." + ) + return None + class DomainCrossingError(SignalError, ValueError): """A `wire()` or `Port↔Node` attachment crosses a `GroundDomain` @@ -193,6 +318,29 @@ class DomainCrossingError(SignalError, ValueError): "would tie the references together and defeat the isolation." ) + def __init__( + self, + *args: Any, + port_domains: Sequence[tuple[str, str]] = (), + source_locations: Sequence[tuple[str, int]] | None = None, + ) -> None: + super().__init__(*args, source_locations=source_locations) + # Pairs of (port_name, domain_name) — at least two distinct + # domains are present for the error to fire at all. + self.port_domains: tuple[tuple[str, str], ...] = tuple(port_domains) + + def suggested_remediation(self) -> str | None: + domains = {d for _, d in self.port_domains} + if len(domains) >= 2: + return ( + "Insert an isolator between the two ground domains — " + "an Optocoupler for slow digital signals, an ADuM-class " + "digital isolator for fast/SPI, or a transformer for " + "AC power. Direct wiring across domains would tie the " + "references together and defeat the isolation." + ) + return None + class PortContentionError(SignalError, ValueError): """A BIDIR pin's external and internal faces hold conflicting @@ -222,6 +370,34 @@ class ForbiddenStateError(CircuitError, ValueError): "(1,1,1); evaluation produces undefined or destructive output." ) + def __init__( + self, + *args: Any, + state_signature: str = '', + port_names: Sequence[str] = (), + source_locations: Sequence[tuple[str, int]] | None = None, + ) -> None: + super().__init__(*args, source_locations=source_locations) + # `state_signature` identifies the canonical forbidden pattern + # (e.g. 'sr_latch_both_active'); per-cell remediations key off + # this so the suggestion can be specific without the base + # class knowing every cell's forbidden taxonomy. + self.state_signature: str = state_signature + self.port_names: tuple[str, ...] = tuple(port_names) + + def suggested_remediation(self) -> str | None: + if (self.state_signature == 'sr_latch_both_active' + and len(self.port_names) == 2): + s, r = self.port_names + return ( + f"Drive {s} and {r} from mutually-exclusive sources " + f"(e.g. invert one input from the other, or wire them " + f"through a one-hot selector), OR use a different " + f"latch type — a D-latch with enable, for instance, " + f"has no forbidden state." + ) + return None + # ------------------------------------------------------------ Part --- @@ -247,6 +423,32 @@ class RefdesError(PartError, ValueError): "refer to the same physical chip." ) + def __init__( + self, + *args: Any, + kind: str = '', + duplicate_refdes: str = '', + source_locations: Sequence[tuple[str, int]] | None = None, + ) -> None: + super().__init__(*args, source_locations=source_locations) + # `kind` is one of '' (legacy / unknown), 'duplicate', + # 'unknown_prefix', 'non_positive', 'duplicate_surface_port'. + # Only the 'duplicate' case currently carries a high-confidence + # fix; the others depend on which specific value the user + # typed wrong. + self.kind: str = kind + self.duplicate_refdes: str = duplicate_refdes + + def suggested_remediation(self) -> str | None: + if self.kind == 'duplicate' and self.duplicate_refdes: + return ( + f"Change one part's `refdes_number=` argument so each " + f"part in the circuit gets a unique {self.duplicate_refdes} " + f"slot — the schematic, BOM, and assembly drawing all " + f"key off the refdes." + ) + return None + class DuplicateRegistrationError(PartError, ValueError): """The component registry rejected a `@register(name)` because @@ -321,6 +523,35 @@ class doesn't match what `MATES_WITH` declared.""" "by the mechanical drawings." ) + def __init__( + self, + *args: Any, + actual_class: str = '', + expected_class: str = '', + partner_class: str = '', + source_locations: Sequence[tuple[str, int]] | None = None, + ) -> None: + super().__init__(*args, source_locations=source_locations) + # `actual_class`: the connector class the user passed as `b`. + # `expected_class`: type(a).MATES_WITH (the class b should + # have been). `partner_class`: type(a), so the suggestion can + # name both sides. All three present → high-confidence + # one-line fix. + self.actual_class: str = actual_class + self.expected_class: str = expected_class + self.partner_class: str = partner_class + + def suggested_remediation(self) -> str | None: + if self.actual_class and self.expected_class and self.partner_class: + return ( + f"Use `{self.expected_class}` to mate with " + f"`{self.partner_class}` — the declared partner class " + f"is unique to that family. If the design really " + f"calls for a `{self.actual_class}`, change the " + f"partner side too so the families match." + ) + return None + class UnmateableError(MatingError, TypeError): """The connector has no in-model partner type declared diff --git a/src/framework/mate.py b/src/framework/mate.py index fd7ca52..49517ad 100644 --- a/src/framework/mate.py +++ b/src/framework/mate.py @@ -47,7 +47,10 @@ class (e.g. USB-A receptacle paired if type(b) is not mates_with: raise IncompatibleMateError( f"{type(a).__name__} mates with {mates_with.__name__}, " - f"not {type(b).__name__}" + f"not {type(b).__name__}", + partner_class=type(a).__name__, + expected_class=mates_with.__name__, + actual_class=type(b).__name__, ) if a.pin_count != b.pin_count: raise PinCountMismatchError( diff --git a/src/framework/port.py b/src/framework/port.py index dd7ad36..32a4702 100644 --- a/src/framework/port.py +++ b/src/framework/port.py @@ -64,7 +64,11 @@ def connect(self, node: Node) -> None: if node.domain is not self.domain: raise DomainCrossingError( f"Ground domain mismatch: port '{self.name}' is in domain " - f"'{self.domain.name}', node '{node.name}' is in domain '{node.domain.name}'" + f"'{self.domain.name}', node '{node.name}' is in domain '{node.domain.name}'", + port_domains=( + (self.name, self.domain.name), + (node.name, node.domain.name), + ), ) self._node = node # Register on the node so orphan-port detection can walk the @@ -88,7 +92,11 @@ def drive(self, value: Any) -> None: except (TypeError, ValueError) as e: raise SignalTypeMismatchError( f"port '{self.name}' expects {self.signal_type.__name__}, " - f"got {type(value).__name__}: {e}" + f"got {type(value).__name__}: {e}", + port_types=( + (self.name, self.signal_type.__name__), + ('', type(value).__name__), + ), ) from e if self._node is not None: self._node.drive(value) diff --git a/src/framework/wire.py b/src/framework/wire.py index 3eb8081..2684bbb 100644 --- a/src/framework/wire.py +++ b/src/framework/wire.py @@ -109,6 +109,7 @@ def _wire_with_attribution( names = ', '.join(f"'{p.name}' ({p.domain.name})" for p in ports) raise DomainCrossingError( f"Cannot wire ports across ground domains: {names}", + port_domains=tuple((p.name, p.domain.name) for p in ports), source_locations=call_locs, ) @@ -117,12 +118,15 @@ def _wire_with_attribution( if len(out_ports) == 0 and len(bidir_ports) == 0: raise FloatingNetError( "wire() has no driver: all ports are IN — nothing drives the node", + kind='all_in', + port_refs=tuple(p.name for p in ports), source_locations=call_locs, ) if len(out_ports) > 1: names = ', '.join(f"'{p.name}'" for p in out_ports) raise ShortCircuitError( f"wire() has multiple drivers ({names}) — short circuit", + drivers=tuple(p.name for p in out_ports), source_locations=call_locs, ) @@ -159,6 +163,9 @@ def _base(t: type) -> type: details = ', '.join(f"'{p.name}': {p.signal_type.__name__}" for p in ports) raise SignalTypeMismatchError( f"Signal type mismatch in wire(): {details}", + port_types=tuple( + (p.name, p.signal_type.__name__) for p in ports + ), source_locations=call_locs, ) diff --git a/tests/cli/test_validate.py b/tests/cli/test_validate.py index d7a01f3..06ffa6f 100644 --- a/tests/cli/test_validate.py +++ b/tests/cli/test_validate.py @@ -71,6 +71,25 @@ def test_short_circuit_wire_extracts_pins() -> None: # Untouched fields stay defaulted. assert details['refdes'] is None assert details['parts'] == [] + # High-confidence remediation (two named drivers) → present. + assert 'remediation' in details + assert 'y_1' in details['remediation'] + assert 'y_2' in details['remediation'] + + +def test_short_circuit_three_way_omits_remediation() -> None: + """When the canonical two-driver shape doesn't apply, the + `remediation` key is omitted entirely so the JSON shape stays + minimal for the low-confidence case.""" + # Reuse the existing two-driver fixture's path machinery by + # constructing inline; the unit test on the framework side + # confirms three+ drivers return None. Here we only confirm that + # an exception whose remediation is None doesn't get a JSON entry. + from cli.validate import _details_with_remediation + from framework.errors import ShortCircuitError + e = ShortCircuitError("three-way short", drivers=('a', 'b', 'c')) + details = _details_with_remediation(e) + assert 'remediation' not in details def test_floating_net_extracts_parts_and_pins() -> None: @@ -209,11 +228,17 @@ def test_details_keys_always_present_on_failure_and_error( args: tuple[str, ...], ) -> None: """The `details` block has a fixed schema for failed/error statuses - so consumers can read every key without per-error-class branching.""" + so consumers can read every key without per-error-class branching. + + `remediation` is the one exception: it's an *additive* key included + only when the framework has a high-confidence fix to suggest, so + the consumer must be tolerant of its presence or absence. + """ _, out = _invoke(*args) details = out['details'] assert isinstance(details, dict) - assert set(details.keys()) == _DETAIL_KEYS + assert _DETAIL_KEYS.issubset(details.keys()) + assert set(details.keys()) - _DETAIL_KEYS <= {'remediation'} # List-typed fields are always lists, never None. assert isinstance(details['pins'], list) assert isinstance(details['parts'], list) diff --git a/tests/framework/test_remediation_suggestions.py b/tests/framework/test_remediation_suggestions.py new file mode 100644 index 0000000..1da5a9e --- /dev/null +++ b/tests/framework/test_remediation_suggestions.py @@ -0,0 +1,328 @@ +"""Per-exception `suggested_remediation()` tests. + +For each defect class with a high-confidence canonical shape, the +framework returns a teaching-toned hint. For shapes where no single +fix dominates, the method returns `None` and lets the designer think +— *silent when confidence is low.* +""" +from __future__ import annotations + +import pytest + +from framework import errors as E + + +# ============================================================== positive +# Canonical high-confidence shapes — each class returns a non-empty +# teaching-toned remediation string. + + +def test_short_circuit_two_drivers_suggests_remove_or_series() -> None: + e = E.ShortCircuitError( + "wire() has multiple drivers ('y_1', 'y_2') — short circuit", + drivers=('y_1', 'y_2'), + ) + rem = e.suggested_remediation() + assert rem is not None + # Names both drivers; offers two alternatives ("OR"). + assert 'y_1' in rem and 'y_2' in rem + assert ' OR ' in rem + assert 'wire()' in rem + assert 'series element' in rem + + +def test_floating_net_multi_bidir_suggests_rail_or_dynamically_driven() -> None: + e = E.FloatingNetError( + "Floating logical net — multiple passive BIDIRs with no driver", + kind='multi_bidir', + port_refs=('Resistor.t1', 'Capacitor.t1'), + ) + rem = e.suggested_remediation() + assert rem is not None + assert 'Rail' in rem + assert 'dynamically_driven=True' in rem + assert ' OR ' in rem + + +def test_incompatible_mate_suggests_correct_partner() -> None: + e = E.IncompatibleMateError( + "USBAReceptacle mates with USBAPlug, not Audio3p5mmTRSJack", + partner_class='USBAReceptacle', + expected_class='USBAPlug', + actual_class='Audio3p5mmTRSJack', + ) + rem = e.suggested_remediation() + assert rem is not None + assert 'USBAPlug' in rem + assert 'USBAReceptacle' in rem + assert 'Audio3p5mmTRSJack' in rem + + +def test_forbidden_state_sr_latch_suggests_mutually_exclusive() -> None: + e = E.ForbiddenStateError( + "Invalid: S and R both active", + state_signature='sr_latch_both_active', + port_names=('s', 'r'), + ) + rem = e.suggested_remediation() + assert rem is not None + assert 'mutually-exclusive' in rem + assert 's' in rem and 'r' in rem + # Offers an alternative latch type. + assert ' OR ' in rem + assert 'D-latch' in rem + + +def test_signal_type_mismatch_suggests_conversion_element() -> None: + e = E.SignalTypeMismatchError( + "Signal type mismatch in wire()", + port_types=(('a', 'Analog'), ('b', 'Digital')), + ) + rem = e.suggested_remediation() + assert rem is not None + # The three canonical conversion elements named in the spec. + assert 'comparator' in rem + assert 'ADC' in rem or 'level-shifter' in rem + + +def test_signal_type_mismatch_non_analog_digital_returns_none() -> None: + """`SignalTypeMismatchError` also fires from `Port.drive()` when + a runtime value can't be coerced to the port's signal_type — e.g. + a string onto a numeric port. The Analog↔Digital conversion + advice doesn't fit that path; the fix there is to supply a + correctly-typed value, which is design-intent territory. Gate + the remediation on the canonical Analog↔Digital shape.""" + # Port.drive() failure path: incoming value's type isn't a signal + # type at all. + e = E.SignalTypeMismatchError( + "port 'in' expects Digital, got str", + port_types=(('in', 'Digital'), ('', 'str')), + ) + assert e.suggested_remediation() is None + + # Two-Analog mismatch (e.g. Volts vs Amps) — not the canonical + # Analog↔Digital shape either. + e = E.SignalTypeMismatchError( + "Volts vs Amps", + port_types=(('a', 'Volts'), ('b', 'Amps')), + ) + assert e.suggested_remediation() is None + + +def test_refdes_duplicate_suggests_unique_refdes_number() -> None: + e = E.RefdesError( + "Duplicate refdes: 'Resistor.R1' and 'Resistor.R1'", + kind='duplicate', + duplicate_refdes='R1', + ) + rem = e.suggested_remediation() + assert rem is not None + assert 'refdes_number=' in rem + assert 'R1' in rem + + +def test_domain_crossing_suggests_isolator() -> None: + e = E.DomainCrossingError( + "Cannot wire ports across ground domains", + port_domains=(('a', 'electrical'), ('b', 'iso_secondary')), + ) + rem = e.suggested_remediation() + assert rem is not None + assert 'Optocoupler' in rem + assert 'transformer' in rem + assert 'isolator' in rem.lower() + + +def test_unconnected_pin_single_port_suggests_wire_to_source() -> None: + e = E.UnconnectedPinError( + "Unconnected mandatory port(s): 'LM7805.in'", + port_refs=('LM7805.in',), + ) + rem = e.suggested_remediation() + assert rem is not None + assert 'LM7805.in' in rem + assert 'mandatory' in rem + + +# ============================================================== negative +# Low-confidence shapes — the method returns None. + + +def test_short_circuit_three_drivers_returns_none() -> None: + e = E.ShortCircuitError( + "three-way short", + drivers=('a', 'b', 'c'), + ) + assert e.suggested_remediation() is None + + +def test_short_circuit_no_driver_data_returns_none() -> None: + """Diagnostics raised without structured driver info shouldn't + fabricate a remediation — no information, no suggestion.""" + e = E.ShortCircuitError("legacy short with no drivers attached") + assert e.suggested_remediation() is None + + +def test_floating_net_all_in_returns_none() -> None: + """The wire-time all-IN case isn't a canonical high-confidence + fix — the user probably forgot a driver entirely, which is design + intent territory.""" + e = E.FloatingNetError( + "wire() has no driver: all ports are IN", + kind='all_in', + port_refs=('a', 'b'), + ) + assert e.suggested_remediation() is None + + +def test_forbidden_state_unknown_signature_returns_none() -> None: + e = E.ForbiddenStateError( + "novel forbidden state", + state_signature='', # unknown / not catalogued + port_names=('x', 'y'), + ) + assert e.suggested_remediation() is None + + +def test_unconnected_pin_multiple_ports_returns_none() -> None: + """Multi-port unconnected → the user has a systemic wiring gap; a + single suggestion for one port would be incomplete.""" + e = E.UnconnectedPinError( + "Unconnected mandatory port(s): 'A.in', 'B.in'", + port_refs=('A.in', 'B.in'), + ) + assert e.suggested_remediation() is None + + +def test_incompatible_mate_missing_class_info_returns_none() -> None: + e = E.IncompatibleMateError("legacy bare-message error") + assert e.suggested_remediation() is None + + +def test_refdes_unknown_prefix_returns_none() -> None: + """Unknown-prefix and non-positive-number RefdesError variants have + no canonical one-line fix — depend on the specific typo.""" + e = E.RefdesError("Unknown refdes prefix 'ZZ'; not in IEEE 315.") + assert e.suggested_remediation() is None + + +def test_base_wirebench_error_returns_none() -> None: + """Default contract on the base class — silence when no override.""" + e = E.WirebenchError("anything") + assert e.suggested_remediation() is None + + +def test_unmateable_returns_none() -> None: + """UnmateableError has no override — fix depends on whether the + user wants to add a partner part or remove the mate() call.""" + e = E.UnmateableError("USB-A receptacle has no in-model mate") + assert e.suggested_remediation() is None + + +# ====================================================== rendering / shape + + +def test_remediation_appears_as_try_line_in_str_output() -> None: + """When `suggested_remediation()` returns non-None, `str(e)` ends + with a `Try: …` paragraph (after Why and Wired at).""" + e = E.ShortCircuitError( + "wire() has multiple drivers ('y_1', 'y_2') — short circuit", + drivers=('y_1', 'y_2'), + ) + rendered = str(e) + lines = rendered.splitlines() + assert any(line.startswith(' Try: ') for line in lines) + # Order: base → Why → (Wired at) → Try. + try_idx = next(i for i, l in enumerate(lines) if l.startswith(' Try: ')) + why_idx = next(i for i, l in enumerate(lines) if l.startswith(' Why: ')) + assert why_idx < try_idx + + +def test_try_line_omitted_when_remediation_is_none() -> None: + e = E.ShortCircuitError("legacy short") + assert 'Try:' not in str(e) + + +# ============================================== discipline-preservation + + +_BANNED_PHRASES = [ + 'bypass', + 'silence', + 'skip the check', + 'disable', + 'ignore the error', + 'except ValueError', + 'except Exception', + 'raise ValueError', + 'raise TypeError', + '# type: ignore', +] + + +def _every_high_confidence_remediation() -> list[str]: + """Build canonical-shape instances of every class with a + high-confidence remediation and return their suggestion strings.""" + return [ + E.ShortCircuitError( + "x", drivers=('a', 'b'), + ).suggested_remediation() or '', + E.FloatingNetError( + "x", kind='multi_bidir', port_refs=('a', 'b'), + ).suggested_remediation() or '', + E.IncompatibleMateError( + "x", + partner_class='USBAReceptacle', + expected_class='USBAPlug', + actual_class='Audio3p5mmTRSJack', + ).suggested_remediation() or '', + E.ForbiddenStateError( + "x", state_signature='sr_latch_both_active', + port_names=('s', 'r'), + ).suggested_remediation() or '', + E.SignalTypeMismatchError( + "x", port_types=(('a', 'Analog'), ('b', 'Digital')), + ).suggested_remediation() or '', + E.RefdesError( + "x", kind='duplicate', duplicate_refdes='R1', + ).suggested_remediation() or '', + E.DomainCrossingError( + "x", port_domains=(('a', 'electrical'), ('b', 'iso')), + ).suggested_remediation() or '', + E.UnconnectedPinError( + "x", port_refs=('LM7805.in',), + ).suggested_remediation() or '', + ] + + +@pytest.mark.parametrize( + 'phrase', _BANNED_PHRASES, +) +def test_no_remediation_suggests_violating_framework_discipline( + phrase: str, +) -> None: + """The framework's strictness is the product. A remediation that + tells the user to bypass / silence / disable a check would erode + that — every suggestion must keep the rules intact and propose a + real circuit-level fix instead.""" + for suggestion in _every_high_confidence_remediation(): + assert phrase.lower() not in suggestion.lower(), ( + f"Remediation suggests violating discipline ({phrase!r} " + f"present): {suggestion!r}" + ) + + +def test_every_high_confidence_remediation_is_teaching_toned() -> None: + """Teaching-toned: explains *what* and offers an alternative when + one exists, rather than barking imperatives.""" + for suggestion in _every_high_confidence_remediation(): + # Imperative-only language ("DO X." with no context) is bad. + # We check the gentler shape: contains either a domain noun or + # an alternative connector ("OR" / "Use …"). + assert any( + marker in suggestion + for marker in (' OR ', 'Use `', 'Insert ', 'Wire ', 'Change ', 'Drive ') + ), f"Remediation isn't teaching-toned: {suggestion!r}" + assert len(suggestion) > 40, ( + f"Remediation too terse to teach: {suggestion!r}" + )