From 8da1665fc8f2bb6867d66db6a90c095cc6bb32e2 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 12 Sep 2026 20:01:21 +0000 Subject: [PATCH 1/6] test: add shared reflection and discovery helpers for conformance tests --- tests/conformance/__init__.py | 1 + tests/conformance/discovery.py | 91 ++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 tests/conformance/__init__.py create mode 100644 tests/conformance/discovery.py diff --git a/tests/conformance/__init__.py b/tests/conformance/__init__.py new file mode 100644 index 000000000..d0c12f7cf --- /dev/null +++ b/tests/conformance/__init__.py @@ -0,0 +1 @@ +"""Architecture and schema conformance tests for python-roborock.""" diff --git a/tests/conformance/discovery.py b/tests/conformance/discovery.py new file mode 100644 index 000000000..7cc0fab42 --- /dev/null +++ b/tests/conformance/discovery.py @@ -0,0 +1,91 @@ +"""Shared discovery utilities for repository conformance tests.""" + +from __future__ import annotations + +import importlib +import inspect +import pkgutil +import types +from collections.abc import Callable, Iterator +from dataclasses import is_dataclass +from functools import lru_cache +from typing import Any + +import pytest + + +@lru_cache(maxsize=8) +def walk_modules(package: types.ModuleType) -> list[types.ModuleType]: + """Recursively walk and import all modules within a package, caching the result.""" + modules: list[types.ModuleType] = [package] + if not hasattr(package, "__path__"): + return modules + for _, modname, _ in pkgutil.walk_packages(package.__path__, package.__name__ + "."): + try: + modules.append(importlib.import_module(modname)) + except (ImportError, AttributeError): + continue + return modules + + +def discover_classes( + package: types.ModuleType, + predicate: Callable[[type], bool], + module_prefix: str | None = None, +) -> list[type]: + """Discover all unique classes within a package that satisfy a given predicate.""" + prefix = module_prefix or package.__name__ + seen: set[type] = set() + result: list[type] = [] + for mod in walk_modules(package): + if not mod.__name__.startswith(prefix): + continue + for _, obj in inspect.getmembers(mod, inspect.isclass): + if not obj.__module__.startswith(prefix): + continue + if obj in seen: + continue + if predicate(obj): + seen.add(obj) + result.append(obj) + return result + + +def discover_subclasses( + package: types.ModuleType, + base_class: type | tuple[type, ...], + exclude: tuple[type, ...] = (), + module_prefix: str | None = None, +) -> list[type]: + """Discover all unique subclasses of base_class in a package, excluding specific classes.""" + return discover_classes( + package, + predicate=lambda cls: issubclass(cls, base_class) and cls not in exclude, + module_prefix=module_prefix, + ) + + +def discover_dataclasses( + package: types.ModuleType, + module_prefix: str | None = None, +) -> list[type]: + """Discover all unique dataclasses defined within a package.""" + return discover_classes( + package, + predicate=is_dataclass, + module_prefix=module_prefix, + ) + + +def to_pytest_params( + classes: list[type], + marks_by_fqn: dict[str, pytest.MarkDecorator] | None = None, +) -> Iterator[Any]: + """Convert a list of classes to parametrized pytest.param objects with fully-qualified IDs.""" + marks_map = marks_by_fqn or {} + for cls in classes: + fqn = f"{cls.__module__}.{cls.__name__}" + if fqn in marks_map: + yield pytest.param(cls, id=fqn, marks=marks_map[fqn]) + else: + yield pytest.param(cls, id=fqn) From 225a311a7ceeafefda94f9df0429a5cb1d6bb2a2 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 12 Sep 2026 20:01:23 +0000 Subject: [PATCH 2/6] test: add wire enum fallback resilience conformance tests --- tests/conformance/test_enum_conformance.py | 119 +++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 tests/conformance/test_enum_conformance.py diff --git a/tests/conformance/test_enum_conformance.py b/tests/conformance/test_enum_conformance.py new file mode 100644 index 000000000..ee94d195d --- /dev/null +++ b/tests/conformance/test_enum_conformance.py @@ -0,0 +1,119 @@ +"""Conformance tests for Roborock enum fallback resilience. + +As defined in AGENTS.md, all wire/firmware enums representing device status, +error codes, and protocol integer codes MUST inherit from RoborockEnum +(defining a lowercase unknown member) or RoborockModeEnum (using from_code_optional) +so unknown codes from firmware updates never crash consumers. +""" + +from __future__ import annotations + +import pytest + +import roborock +from roborock.data.code_mappings import RoborockEnum, RoborockModeEnum +from tests.conformance.discovery import discover_subclasses, to_pytest_params + +# Baseline inventory of legacy RoborockEnum classes that do not yet define an +# explicit `unknown` member. When newer firmware emits an undocumented code, +# RoborockEnum._missing_ currently falls back to `next(item for item in cls)`, +# which arbitrarily defaults to the first member. +# As these enums are remediated, remove them from this baseline. +KNOWN_ENUMS_MISSING_UNKNOWN = { + "roborock.data.dyad.dyad_code_mappings.DyadBrushSpeed", + "roborock.data.dyad.dyad_code_mappings.DyadCleanMode", + "roborock.data.dyad.dyad_code_mappings.DyadCleanser", + "roborock.data.dyad.dyad_code_mappings.DyadError", + "roborock.data.dyad.dyad_code_mappings.DyadMode", + "roborock.data.dyad.dyad_code_mappings.DyadSelfCleanLevel", + "roborock.data.dyad.dyad_code_mappings.DyadSelfCleanMode", + "roborock.data.dyad.dyad_code_mappings.DyadSuction", + "roborock.data.dyad.dyad_code_mappings.DyadWarmLevel", + "roborock.data.dyad.dyad_code_mappings.DyadWaterLevel", + "roborock.data.v1.v1_code_mappings.CleanFluidStatus", + "roborock.data.v1.v1_code_mappings.ClearWaterBoxStatus", + "roborock.data.v1.v1_code_mappings.DirtyWaterBoxStatus", + "roborock.data.v1.v1_code_mappings.DustBagStatus", + "roborock.data.v1.v1_code_mappings.RoborockCleanType", + "roborock.data.v1.v1_code_mappings.RoborockDockErrorCode", + "roborock.data.v1.v1_code_mappings.RoborockDssCodes", + "roborock.data.v1.v1_code_mappings.RoborockErrorCode", + "roborock.data.v1.v1_code_mappings.RoborockFanPowerCode", + "roborock.data.v1.v1_code_mappings.RoborockFanSpeedE2", + "roborock.data.v1.v1_code_mappings.RoborockFanSpeedV1", + "roborock.data.v1.v1_code_mappings.RoborockFanSpeedV2", + "roborock.data.v1.v1_code_mappings.RoborockFanSpeedV3", + "roborock.data.v1.v1_code_mappings.RoborockFinishReason", + "roborock.data.v1.v1_code_mappings.RoborockInCleaning", + "roborock.data.v1.v1_code_mappings.RoborockMopIntensityCode", + "roborock.data.v1.v1_code_mappings.RoborockMopIntensityV2", + "roborock.data.v1.v1_code_mappings.RoborockStartType", + "roborock.data.zeo.zeo_code_mappings.ZeoDetergentExpansionType", + "roborock.data.zeo.zeo_code_mappings.ZeoDetergentType", + "roborock.data.zeo.zeo_code_mappings.ZeoDirtDetectionStatus", + "roborock.data.zeo.zeo_code_mappings.ZeoDryAndCare", + "roborock.data.zeo.zeo_code_mappings.ZeoDryerStartError", + "roborock.data.zeo.zeo_code_mappings.ZeoDryingMethod", + "roborock.data.zeo.zeo_code_mappings.ZeoDryingMode", + "roborock.data.zeo.zeo_code_mappings.ZeoError", + "roborock.data.zeo.zeo_code_mappings.ZeoFeatureBits", + "roborock.data.zeo.zeo_code_mappings.ZeoMode", + "roborock.data.zeo.zeo_code_mappings.ZeoProgram", + "roborock.data.zeo.zeo_code_mappings.ZeoRinse", + "roborock.data.zeo.zeo_code_mappings.ZeoSoak", + "roborock.data.zeo.zeo_code_mappings.ZeoSoftenerExpansionType", + "roborock.data.zeo.zeo_code_mappings.ZeoSoftenerType", + "roborock.data.zeo.zeo_code_mappings.ZeoSpin", + "roborock.data.zeo.zeo_code_mappings.ZeoState", + "roborock.data.zeo.zeo_code_mappings.ZeoSteamVolume", + "roborock.data.zeo.zeo_code_mappings.ZeoTemperature", + "roborock.roborock_message.RoborockB01Protocol", + "roborock.roborock_message.RoborockDataProtocol", + "roborock.roborock_message.RoborockDyadDataProtocol", + "roborock.roborock_message.RoborockMessageProtocol", + "roborock.roborock_message.RoborockMowerDataProtocol", + "roborock.roborock_message.RoborockZeoProtocol", +} + +_XFAIL_MARKS = { + fqn: pytest.mark.xfail( + reason=f"{fqn} lacks an explicit 'unknown' member; defaults to first item on unknown codes", + strict=True, + ) + for fqn in KNOWN_ENUMS_MISSING_UNKNOWN +} + +_ALL_ROBOROCK_ENUMS = discover_subclasses(roborock, RoborockEnum, exclude=(RoborockEnum,)) +_ALL_MODE_ENUMS = discover_subclasses(roborock, RoborockModeEnum, exclude=(RoborockModeEnum,)) + + +@pytest.mark.parametrize("enum_cls", to_pytest_params(_ALL_ROBOROCK_ENUMS, marks_by_fqn=_XFAIL_MARKS)) +def test_roborock_enum_has_unknown_fallback(enum_cls: type[RoborockEnum]) -> None: + """All RoborockEnum subclasses must define an explicit 'unknown' member.""" + assert hasattr(enum_cls, "unknown"), ( + f"{enum_cls.__module__}.{enum_cls.__name__} must define an 'unknown' member to prevent " + "crashing or defaulting to arbitrary states on new firmware." + ) + # Also verify that resolving an unknown int code returns the unknown member + assert enum_cls(99999) == enum_cls.unknown + + +@pytest.mark.parametrize("mode_enum_cls", to_pytest_params(_ALL_MODE_ENUMS)) +def test_roborock_mode_enum_handles_unknown_code(mode_enum_cls: type[RoborockModeEnum]) -> None: + """RoborockModeEnum subclasses must return None when an unknown code is provided.""" + assert mode_enum_cls.from_code_optional(99999) is None + + +def test_known_missing_unknown_baseline_inventory() -> None: + """Ensure the inventory of legacy unresilient enums remains strictly synchronized.""" + current_missing = {f"{cls.__module__}.{cls.__name__}" for cls in _ALL_ROBOROCK_ENUMS if not hasattr(cls, "unknown")} + new_untracked = current_missing - KNOWN_ENUMS_MISSING_UNKNOWN + assert not new_untracked, ( + f"New RoborockEnum classes without 'unknown' member detected: {new_untracked}. " + "All new wire enums must define an 'unknown' member (e.g. unknown = -1 or 0)." + ) + stale_in_baseline = KNOWN_ENUMS_MISSING_UNKNOWN - current_missing + assert not stale_in_baseline, ( + f"Enums in KNOWN_ENUMS_MISSING_UNKNOWN are no longer missing 'unknown': {stale_in_baseline}. " + "Please remove them from KNOWN_ENUMS_MISSING_UNKNOWN." + ) From ae89cd0b0486a5e34d0f77b0082da575549b92c7 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 12 Sep 2026 20:01:24 +0000 Subject: [PATCH 3/6] test: add exception hierarchy rooting conformance tests --- .../conformance/test_exception_conformance.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/conformance/test_exception_conformance.py diff --git a/tests/conformance/test_exception_conformance.py b/tests/conformance/test_exception_conformance.py new file mode 100644 index 000000000..dcfda65a7 --- /dev/null +++ b/tests/conformance/test_exception_conformance.py @@ -0,0 +1,26 @@ +"""Conformance tests for exception hierarchy. + +As defined in AGENTS.md, all custom exceptions in python-roborock MUST inherit +from `roborock.exceptions.RoborockException` so downstream consumers (such as +Home Assistant Core) can reliably catch and handle library errors without crashing. +""" + +from __future__ import annotations + +import pytest + +import roborock +from roborock.exceptions import RoborockException +from tests.conformance.discovery import discover_subclasses, to_pytest_params + + +@pytest.mark.parametrize( + "exception_cls", + to_pytest_params(discover_subclasses(roborock, BaseException, exclude=(BaseException, Exception))), +) +def test_all_custom_exceptions_inherit_from_roborock_exception(exception_cls: type[BaseException]) -> None: + """Every custom exception defined in roborock must inherit from RoborockException.""" + assert issubclass(exception_cls, RoborockException), ( + f"{exception_cls.__module__}.{exception_cls.__name__} does not inherit from RoborockException. " + "All library exceptions must inherit from RoborockException per AGENTS.md." + ) From ad2040408e55d31cd9b62ed4ef9f7f13526c599d Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 12 Sep 2026 20:01:26 +0000 Subject: [PATCH 4/6] test: add trait boundary purity conformance tests --- .../test_trait_boundary_conformance.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 tests/conformance/test_trait_boundary_conformance.py diff --git a/tests/conformance/test_trait_boundary_conformance.py b/tests/conformance/test_trait_boundary_conformance.py new file mode 100644 index 000000000..6e0bbd0b8 --- /dev/null +++ b/tests/conformance/test_trait_boundary_conformance.py @@ -0,0 +1,51 @@ +"""Conformance tests for trait boundary purity. + +As defined in AGENTS.md, Traits receive only abstract communication channels +(e.g., `Channel`, `RpcChannel`) or domain models/feature flags. They must NEVER +accept raw transport sockets, IP addresses, credentials, local keys, or AES encryption keys. +""" + +from __future__ import annotations + +import inspect + +import pytest + +import roborock.devices.traits +from roborock.devices.traits import Trait +from roborock.devices.traits.v1.common import V1TraitMixin +from tests.conformance.discovery import discover_classes, to_pytest_params + +FORBIDDEN_PARAMS = { + "local_key", + "token", + "ip", + "ip_address", + "host", + "socket", + "transport", + "aes_key", + "password", + "secret", +} + + +def _is_trait_class(cls: type) -> bool: + if cls in (Trait, V1TraitMixin): + return False + return issubclass(cls, (Trait, V1TraitMixin)) or cls.__name__.endswith(("Trait", "Api")) + + +@pytest.mark.parametrize( + "trait_cls", + to_pytest_params(discover_classes(roborock.devices.traits, _is_trait_class)), +) +def test_trait_constructor_does_not_leak_transport_or_credentials(trait_cls: type) -> None: + """Trait constructor parameters must never include transport sockets, IPs, or credentials.""" + sig = inspect.signature(trait_cls) + param_names = set(sig.parameters.keys()) - {"self", "args", "kwargs"} + violations = param_names & FORBIDDEN_PARAMS + assert not violations, ( + f"{trait_cls.__module__}.{trait_cls.__name__}.__init__ accepts forbidden transport/credential " + f"parameters {violations}. Per AGENTS.md, traits must receive only abstract channels or domain models." + ) From 4341d340522893fc49904e9e5c1303142b414ce9 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 12 Sep 2026 20:01:29 +0000 Subject: [PATCH 5/6] test: add data model RoborockBase inheritance conformance tests --- tests/conformance/test_model_conformance.py | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/conformance/test_model_conformance.py diff --git a/tests/conformance/test_model_conformance.py b/tests/conformance/test_model_conformance.py new file mode 100644 index 000000000..52979d662 --- /dev/null +++ b/tests/conformance/test_model_conformance.py @@ -0,0 +1,26 @@ +"""Conformance tests for data model inheritance. + +As defined in AGENTS.md, structured domain and wire models in `roborock.data` +MUST inherit from `RoborockBase` to ensure standard `as_dict()` serialization +and `from_dict()` deserialization across consumers. +""" + +from __future__ import annotations + +import pytest + +import roborock.data +from roborock.data.containers import RoborockBase +from tests.conformance.discovery import discover_dataclasses, to_pytest_params + + +@pytest.mark.parametrize( + "model_cls", + to_pytest_params(discover_dataclasses(roborock.data)), +) +def test_data_model_subclasses_roborock_base(model_cls: type) -> None: + """All domain dataclasses in roborock.data must inherit from RoborockBase.""" + assert issubclass(model_cls, RoborockBase), ( + f"{model_cls.__module__}.{model_cls.__name__} is a dataclass but does not inherit from RoborockBase. " + "Per AGENTS.md, domain containers must subclass RoborockBase for serialization." + ) From 26040390f5c48eb0b64c358fb61b9f37f0596798 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 12 Sep 2026 21:18:49 +0000 Subject: [PATCH 6/6] test: harden conformance discovery, trait boundaries, and enum resilience --- tests/conformance/discovery.py | 5 +- tests/conformance/test_enum_conformance.py | 52 +++++++++++++++++-- .../test_trait_boundary_conformance.py | 48 ++++++++++++++--- 3 files changed, 91 insertions(+), 14 deletions(-) diff --git a/tests/conformance/discovery.py b/tests/conformance/discovery.py index 7cc0fab42..c96aef45e 100644 --- a/tests/conformance/discovery.py +++ b/tests/conformance/discovery.py @@ -21,10 +21,7 @@ def walk_modules(package: types.ModuleType) -> list[types.ModuleType]: if not hasattr(package, "__path__"): return modules for _, modname, _ in pkgutil.walk_packages(package.__path__, package.__name__ + "."): - try: - modules.append(importlib.import_module(modname)) - except (ImportError, AttributeError): - continue + modules.append(importlib.import_module(modname)) return modules diff --git a/tests/conformance/test_enum_conformance.py b/tests/conformance/test_enum_conformance.py index ee94d195d..a22eb23b7 100644 --- a/tests/conformance/test_enum_conformance.py +++ b/tests/conformance/test_enum_conformance.py @@ -8,11 +8,14 @@ from __future__ import annotations +import enum +import inspect + import pytest import roborock from roborock.data.code_mappings import RoborockEnum, RoborockModeEnum -from tests.conformance.discovery import discover_subclasses, to_pytest_params +from tests.conformance.discovery import discover_subclasses, to_pytest_params, walk_modules # Baseline inventory of legacy RoborockEnum classes that do not yet define an # explicit `unknown` member. When newer firmware emits an undocumented code, @@ -87,6 +90,33 @@ _ALL_MODE_ENUMS = discover_subclasses(roborock, RoborockModeEnum, exclude=(RoborockModeEnum,)) +# Enums in wire code mapping modules that intentionally remain standard Enum/StrEnum/IntEnum +# (e.g., domain categories, product nicknames, or outgoing command identifiers). +ALLOWED_NON_RESILIENT_CODE_MAPPING_ENUMS = { + "roborock.data.b01_q10.b01_q10_code_mappings.RemoteCommand", + "roborock.data.code_mappings.RoborockCategory", + "roborock.data.code_mappings.RoborockProductNickname", + "roborock.data.v1.v1_code_mappings.RoborockDockState", +} + + +def _discover_code_mapping_enums() -> list[type[enum.Enum]]: + enums: list[type[enum.Enum]] = [] + for mod in walk_modules(roborock): + if "code_mapping" in mod.__name__: + for _, obj in inspect.getmembers(mod, inspect.isclass): + if ( + obj.__module__ == mod.__name__ + and issubclass(obj, enum.Enum) + and obj not in (enum.Enum, enum.IntEnum, enum.StrEnum, RoborockEnum, RoborockModeEnum) + ): + enums.append(obj) + return sorted(enums, key=lambda c: f"{c.__module__}.{c.__name__}") + + +_ALL_CODE_MAPPING_ENUMS = _discover_code_mapping_enums() + + @pytest.mark.parametrize("enum_cls", to_pytest_params(_ALL_ROBOROCK_ENUMS, marks_by_fqn=_XFAIL_MARKS)) def test_roborock_enum_has_unknown_fallback(enum_cls: type[RoborockEnum]) -> None: """All RoborockEnum subclasses must define an explicit 'unknown' member.""" @@ -94,14 +124,28 @@ def test_roborock_enum_has_unknown_fallback(enum_cls: type[RoborockEnum]) -> Non f"{enum_cls.__module__}.{enum_cls.__name__} must define an 'unknown' member to prevent " "crashing or defaulting to arbitrary states on new firmware." ) - # Also verify that resolving an unknown int code returns the unknown member - assert enum_cls(99999) == enum_cls.unknown + # Derive an integer sentinel guaranteed to not exist in the enum + sentinel = max(item.value for item in enum_cls) + 1 if list(enum_cls) else 99999 + assert enum_cls(sentinel) == enum_cls.unknown @pytest.mark.parametrize("mode_enum_cls", to_pytest_params(_ALL_MODE_ENUMS)) def test_roborock_mode_enum_handles_unknown_code(mode_enum_cls: type[RoborockModeEnum]) -> None: """RoborockModeEnum subclasses must return None when an unknown code is provided.""" - assert mode_enum_cls.from_code_optional(99999) is None + sentinel = max(member.code for member in mode_enum_cls) + 1 if list(mode_enum_cls) else 99999 + assert mode_enum_cls.from_code_optional(sentinel) is None + + +@pytest.mark.parametrize("enum_cls", to_pytest_params(_ALL_CODE_MAPPING_ENUMS)) +def test_code_mapping_enums_inherit_resilient_bases(enum_cls: type[enum.Enum]) -> None: + """Enums in wire code mapping modules must inherit from RoborockEnum or RoborockModeEnum.""" + fqn = f"{enum_cls.__module__}.{enum_cls.__name__}" + if fqn in ALLOWED_NON_RESILIENT_CODE_MAPPING_ENUMS: + return + assert issubclass(enum_cls, (RoborockEnum, RoborockModeEnum)), ( + f"{fqn} in a code mapping module does not inherit from RoborockEnum or RoborockModeEnum. " + "Wire status and error code enums must use resilient enum bases to handle unknown firmware codes." + ) def test_known_missing_unknown_baseline_inventory() -> None: diff --git a/tests/conformance/test_trait_boundary_conformance.py b/tests/conformance/test_trait_boundary_conformance.py index 6e0bbd0b8..40af0ba4f 100644 --- a/tests/conformance/test_trait_boundary_conformance.py +++ b/tests/conformance/test_trait_boundary_conformance.py @@ -7,13 +7,18 @@ from __future__ import annotations +import asyncio import inspect +import socket +import typing import pytest import roborock.devices.traits from roborock.devices.traits import Trait from roborock.devices.traits.v1.common import V1TraitMixin +from roborock.devices.transport.local_channel import LocalChannel, LocalChannelParams +from roborock.devices.transport.mqtt_channel import MqttParams, MqttSession from tests.conformance.discovery import discover_classes, to_pytest_params FORBIDDEN_PARAMS = { @@ -27,8 +32,29 @@ "aes_key", "password", "secret", + "key", } +FORBIDDEN_TYPES = ( + socket.socket, + asyncio.BaseTransport, + LocalChannel, + LocalChannelParams, + MqttParams, + MqttSession, +) + + +def _extract_types(annotation: typing.Any) -> set[type]: + """Recursively extract underlying concrete types from type hints and unions.""" + if isinstance(annotation, type): + return {annotation} + args = typing.get_args(annotation) + types_found: set[type] = set() + for arg in args: + types_found |= _extract_types(arg) + return types_found + def _is_trait_class(cls: type) -> bool: if cls in (Trait, V1TraitMixin): @@ -43,9 +69,19 @@ def _is_trait_class(cls: type) -> bool: def test_trait_constructor_does_not_leak_transport_or_credentials(trait_cls: type) -> None: """Trait constructor parameters must never include transport sockets, IPs, or credentials.""" sig = inspect.signature(trait_cls) - param_names = set(sig.parameters.keys()) - {"self", "args", "kwargs"} - violations = param_names & FORBIDDEN_PARAMS - assert not violations, ( - f"{trait_cls.__module__}.{trait_cls.__name__}.__init__ accepts forbidden transport/credential " - f"parameters {violations}. Per AGENTS.md, traits must receive only abstract channels or domain models." - ) + for param_name, param in sig.parameters.items(): + if param_name in ("self", "args", "kwargs"): + continue + + assert param_name not in FORBIDDEN_PARAMS, ( + f"{trait_cls.__module__}.{trait_cls.__name__}.__init__ accepts forbidden transport/credential " + f"parameter '{param_name}'. Per AGENTS.md, traits must receive only abstract channels or domain models." + ) + + types_found = _extract_types(param.annotation) + for t in types_found: + assert not issubclass(t, FORBIDDEN_TYPES), ( + f"{trait_cls.__module__}.{trait_cls.__name__}.__init__ accepts parameter '{param_name}' " + f"typed with forbidden low-level transport/credential class {t.__name__}. " + "Traits must depend only on abstract channels (e.g. Channel, RpcChannel)." + )