Przejdź do treści

INW020 adapter-conformance

Co robi

Opt-in, a po włączeniu błąd. Sprawdza, czy każda publiczna klasa najwyższego poziomu w module adaptera deklaruje port, który implementuje, linią zgodności: przypisaniem z adnotacją w bloku if TYPE_CHECKING: (albo if typing.TYPE_CHECKING:) na poziomie modułu, którego wartość buduje tę klasę albo jest tą klasą.

if TYPE_CHECKING:
    _: OrderRepository = SqlOrderRepository(...)    # or
    _: type[OrderRepository] = SqlOrderRepository

Nazwa zmiennej jest dowolna; konwencją jest _. Argumenty mogą być dowolne, także ..., i liczy się też SqlOrderRepository[int](...). Zgłaszane są trzy rzeczy:

  • Brak linii zgodności, na nazwie klasy. Przykładowa linia w komunikacie nazywa port, gdy moduł importuje dokładnie jedną klasę z modułu portu, a w przeciwnym razie pisze <Port>.
  • Linia, która się wykonuje, na tej linii: to samo przypisanie z adnotacją na poziomie modułu poza if TYPE_CHECKING:, także w gałęzi else. Przy każdym imporcie modułu buduje adapter albo wymaga, żeby port był importowany w czasie działania.
  • Adnotacja, która nie nazywa portu, na adnotacji: gdy ustawiono ports, klasa spoza tych modułów; z ports i bez, Any albo object, które przyjmują każdą wartość.

Klasa nie potrzebuje linii, gdy jej nazwa zaczyna się od _, gdy sama jest portem (Protocol albo ABC) albo gdy jest rekordem: wyjątkiem, enumem, modelem Pydantic, named tuple, typed dict albo dataclassą. Zobacz Konfiguracja.

Bez modules reguła sprawdza każdy moduł z segmentem adapters w nazwie: shop.adapters, shop.adapters.outbound.orders. Ustaw modules, żeby sprawdzać inne moduły.

Dlaczego to źle

Port to tutaj typing.Protocol, a adapter implementuje go, mając jego składowe; dziedziczenie po protokole „nie jest konieczne dla sprawdzania typów” (specyfikacja typowania, Protocols). W module adaptera nic więc nie mówi, jaki port implementuje, i nic tego nie sprawdza, dopóki korzeń kompozycji nie przekaże adaptera tam, gdzie oczekiwany jest port, o ile w ogóle to zrobi. Agent, który zmieni nazwę metody portu albo doda nową, dowie się o tym dopiero w czasie działania.

Linia zgodności zamyka tę lukę. TYPE_CHECKING ma wartość False w czasie działania, a statyczne type checkery przyjmują True (typing.TYPE_CHECKING), więc linia niczego nie buduje, a mimo to jest sprawdzana: przypisywalność do protokołu jest strukturalna, a ty zgłasza wartość niepasującą do zadeklarowanego typu jako invalid-assignment, tak samo jak mypy i Pyright. Zmienna z adnotacją type[Port] przyjmuje klasę konkretną, której instancje implementują protokół (type[] and class objects vs protocols). INW020 sprawdza, czy linia istnieje; type checker sprawdza, co ona mówi (ADR-046).

Przykład

pyproject.toml
[tool.inwards]
layers = [
  { name = "domain", modules = ["shop.domain"] },
  { name = "application", modules = ["shop.application"] },
  { name = "adapters", modules = ["shop.adapters"] },
]

[tool.inwards.rules]
extend-select = ["INW020"]

[tool.inwards.rules.adapter-conformance]
ports = ["shop.application.ports"]
shop/application/ports/orders.py
from typing import Protocol


class OrderRepository(Protocol):
    def get(self, order_id: int) -> dict: ...
shop/domain/orders.py
class Order:
    pass
shop/adapters/orders.py
from typing import TYPE_CHECKING

from shop.application.ports.orders import OrderRepository
from shop.domain.orders import Order


class StorageError(Exception):
    pass


class SqlOrderRepository:
    def get(self, order_id: int) -> dict:
        return {"id": order_id}


class CachedOrderRepository:
    def get(self, order_id: int) -> dict:
        return {"id": order_id}


_: OrderRepository = SqlOrderRepository()

if TYPE_CHECKING:
    __: Order = CachedOrderRepository()
inwards check shop
shop/adapters/orders.py:21:1: INW020 `_: OrderRepository = SqlOrderRepository()` in adapter module `shop.adapters.orders` is outside `if TYPE_CHECKING:`, so it runs when the module is imported and builds a `SqlOrderRepository`. A conformance line is only for the type checker.
  fix: Move the line into an `if TYPE_CHECKING:` block at the end of the module.
    1. Move the line into an `if TYPE_CHECKING:` block at module level (`from typing import TYPE_CHECKING`), at the end of the module.
    2. Move the port's import into the same block if nothing else in the module uses it at runtime.
  docs: https://sircypkowskyy.github.io/inwards/rules/INW020/

shop/adapters/orders.py:24:9: INW020 The conformance line for `CachedOrderRepository` in adapter module `shop.adapters.orders` is annotated `shop.domain.orders.Order`, which isn't a class from a port module (`ports`), so the type checker doesn't check the class against a port.
  fix: Annotate the line with the port `CachedOrderRepository` implements.
    1. Replace `Order` with the port `CachedOrderRepository` implements, imported from its port module under `if TYPE_CHECKING:`.
    2. If that port lives outside the modules `ports` lists in [tool.inwards.rules.adapter-conformance], ask the user to add its module there. Don't edit [tool.inwards] yourself.
  docs: https://sircypkowskyy.github.io/inwards/rules/INW020/

StorageError przechodzi, bo jest wyjątkiem. Po poprawce obie linie są pod if TYPE_CHECKING: i mają adnotację z portem.

shop/adapters/orders.py
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from shop.application.ports.orders import OrderRepository


class StorageError(Exception):
    pass


class SqlOrderRepository:
    def get(self, order_id: int) -> dict:
        return {"id": order_id}


class CachedOrderRepository:
    def get(self, order_id: int) -> dict:
        return {"id": order_id}


if TYPE_CHECKING:
    _: OrderRepository = SqlOrderRepository()
    __: type[OrderRepository] = CachedOrderRepository
inwards check shop

Jak działa

Plik jest parsowany tylko wtedy, gdy INW020 jest włączona dla jego modułu, moduł jest modułem adaptera, a w tekście jest słowo class. Jedno parsowanie na taki plik; żaden inny moduł nie jest czytany, więc hook po każdej edycji, edytor i MCP zgłaszają ją przy edycji, która ją psuje.

  • Klasy. Klasy najwyższego poziomu, których nazwa nie zaczyna się od _. Klasa jest zwolniona, gdy ma dozwolony dekorator albo bazę, gdy jej bazą jest klasa z tego samego pliku zwolniona w ten sposób, albo gdy wymienia Protocol, abc.ABC lub metaclass=abc.ABCMeta.
  • Nazwy są kwalifikowane przez importy pliku, tak jak w INW014: liczą się import typing z typing.TYPE_CHECKING, importy z aliasem i typing_extensions. Nazwa, której plik nie importuje, to nazwa wbudowana, pisana bez prefiksu (Exception, object).
  • Linie. Przypisanie z adnotacją bezpośrednio na poziomie modułu albo bezpośrednio w gałęzi if na poziomie modułu. Jego wartość to Name(...) albo Name[...](...) z dowolną adnotacją, albo samo Name pod type[...] (także typing.Type[...]), gdzie Name to klasa, którą reguła sprawdza. Linia w ciele if TYPE_CHECKING: albo elif TYPE_CHECKING: jest dla type checkera; każda inna się wykonuje.
  • Porty. Gdy ustawiono ports, głowa adnotacji (Repo z Repo[int]) musi wskazywać nazwę w stylu CapWords zdefiniowaną w jednym z tych modułów. Bez tej opcji port w komunikacie to jedyna nazwa CapWords, którą moduł importuje z modułu z segmentem ports.
  • Jedno zgłoszenie na problem. Klasa z linią, która się wykonuje, albo z linią, której adnotacja nie nazywa portu, nie jest dodatkowo zgłaszana jako klasa bez linii.

Jak naprawić

  1. Zaimportuj port pod if TYPE_CHECKING:.
  2. Dodaj _: Port = Adapter(...) albo _: type[Port] = Adapter w bloku if TYPE_CHECKING: na końcu modułu; dla klasy, która implementuje kilka portów, jedną linię na port.
  3. Jeśli klasa jest pomocnicza, a nie jest adapterem, zmień jej nazwę na _Name albo przenieś ją poza moduł adaptera. Jeśli jest rekordem na własnej bazie projektu, poproś osobę odpowiedzialną za konfigurację o dodanie tej bazy do extend-allow-bases.

Bezpieczeństwo poprawki

Brak automatycznej poprawki (autofix: false). Tego, jaki port implementuje klasa, nie ma w kodzie; właśnie to mówi linia.

Konfiguracja

Ważność: błąd. Opt-in: włącz ją przez extend-select. Presety clean i hexagonal polecenia inwards init włączają INW020 jako ostrzeżenie, z modules ustawionym na warstwę adapterów wyjściowych (driven) i ports na application.ports. Opcje są w [tool.inwards.rules.adapter-conformance] (tabele opcji):

pyproject.toml
[tool.inwards.rules]
extend-select = ["INW020"]

[tool.inwards.rules.adapter-conformance]
modules = ["shop.infrastructure"]       # optional: the adapter modules
ports = ["shop.application.ports"]      # optional: annotations must name a class from these
extend-allow-bases = ["shop.kit.Schema"]
  • modules: prefiksy modułów albo selektory, jak w modules warstwy. Gdy jest ustawione, INW020 sprawdza pasujące moduły zamiast tych z segmentem adapters.
  • ports: prefiksy albo selektory modułów portów. Gdy jest ustawione, adnotacja linii musi nazywać klasę z jednego z nich, a komunikat nazywa tę, którą moduł z nich importuje.
  • allow-bases, extend-allow-bases i allow-decorators: jak w INW014, z tymi samymi wartościami domyślnymi (wyjątki, pydantic.BaseModel, NamedTuple, TypedDict, enum.*; dekoratory dataclass, pydantic i attrs).
  • check-tests: true sprawia, że INW020 zgłasza też w modułach testowych, które domyślnie pomija (tabele opcji).

Żeby zaakceptować jedno zgłoszenie, wycisz je w linii, na którą wskazuje: # inwards: ignore[INW020] reason="..." w linii class, w linii zgodności albo w linii adnotacji.

Szablon może włączyć INW020 dla jednej ze swoich ról: adapters = { adapter-conformance = true } ustawia modules na moduły roli.

Znane ograniczenia

  • Adapter czy klasa pomocnicza. Reguła ich nie odróżnia: każda publiczna klasa w module adaptera potrzebuje linii. Klasa pomocnicza potrzebuje podkreślenia, dozwolonej bazy albo węższego modules.
  • Bazy projektu. Model Pydantic na własnej bazie projektu (class Cursor(Schema) z Schema z polar.kit.schemas) jest zgłaszany, dopóki tej bazy nie ma w extend-allow-bases: baza jest czytana po nazwie, a nie śledzona.
  • Co mówi linia. To, czy klasa pasuje do portu, sprawdza type checker, a w projekcie, który go nie uruchamia, linii nikt nie sprawdza.
  • Czytane linie. Linia zagnieżdżona głębiej niż gałąź if na poziomie modułu (w try albo w if wewnątrz if TYPE_CHECKING:) nie jest czytana. Adnotacja w cudzysłowie (_: "Port" = Adapter(...)) liczy się jako linia, ale jej port nie jest sprawdzany.
  • Porty w stylu CapWords. Port w komunikacie i sprawdzanie ports traktują nazwę CapWords jako klasę, a nazwę małymi literami jako moduł, bo żaden inny plik nie jest czytany.
  • Pomiar na korpusie (2026-10-11, pięć repozytoriów z bench/corpus.json, warstwy z manifestu plus select = ["INW020"]): 8 zgłoszeń w fastapi-clean-example, czyli jego 8 adapterów, które zamiast tego dziedziczą po portach; 2 w Polarze, jego StripeAdapter i StripeExtractionCursor, model Pydantic na własnej bazie projektu Schema; żadnego w pozostałych trzech, które nie mają modułów adapters. Żaden projekt w korpusie nie stosuje jeszcze tej konwencji, dlatego reguła jest opt-in.

Źródła