anime-db / plugin-contracts
Plugin contracts (interfaces and DTOs) for AnimeDB v2 plugins
Package info
github.com/anime-db/anime-db-plugin-contracts
Type:phpstan-extension
pkg:composer/anime-db/plugin-contracts
Requires
- php: >=8.5
- composer/semver: ^3.4
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.95
- phpstan/phpstan: ^2.2
- phpunit/phpunit: ^10.5
- symfony/http-client: 8.1.*
- symfony/process: 8.1.*
Suggests
- phpstan/extension-installer: Automatically registers extension.neon, so the rules from this package are picked up with no manual configuration.
Provides
None
Conflicts
None
Replaces
None
README
Контракты плагинов (интерфейсы и DTO) для плагинов AnimeDB v2.
Этот пакет определяет только контракт между хост-приложением AnimeDB и его плагинами: интерфейсы и плоские DTO. Он не содержит ни реализации плагинов, ни бизнес-логики хост-приложения.
Установка
composer require anime-db/plugin-contracts
Пакет пока не опубликован на Packagist — до публикации добавьте в свой
composer.json VCS-репозиторий:
{
"repositories": [
{
"type": "vcs",
"url": "https://github.com/anime-db/anime-db-plugin-contracts.git"
}
]
}
Оглавление
- Что реализует плагин
- Что предоставляет ядро
- Общие примитивы
- Манифест плагина (
manifest.json) - PHPStan-правила
Что реализует плагин
Интерфейсы, которые реализует сам плагин под конкретный внешний источник.
SearchByPluginInterface, SyncInterface и транзитивно FillerInterface
наследуют базовую способность ExternalIdResolutionInterface;
CatalogWidgetInterface, EntryWidgetInterface,
DownloadCandidateSearchInterface и SettingsPageInterface — нет.
ExternalIdResolutionInterface
Способность резолвить собственный внешний id плагина. Единственный метод —
resolveExternalId() — нужен интерфейсам, которым требуется эта способность:
SearchByPluginInterface, SyncInterface и транзитивно FillerInterface.
Интерфейс называет способность, а не категорию плагина — по этой же причине
его не реализует DownloadCandidateSearchInterface: search()
принимает свободный текстовый запрос, а не список ссылок, а идентичность
кандидата несёт AnimeSearchResultItem::$externalId.
Виджетные интерфейсы (CatalogWidgetInterface, EntryWidgetInterface) его
тоже не наследуют: хост никогда не вызывает resolveExternalId() у
виджета, а уже отрезолвленный внешний id записи виджет читает через
CatalogReaderInterface. Виджет,
которому нужен собственный внешний id сверх этого, реализует интерфейс
дополнительно и явно.
Это не общий предок всех плагинов: плагин, который реагирует на события
каталога и не обращается ни к какому внешнему источнику (type: local в
манифесте), не реализует этот интерфейс — resolveExternalId() ему не нужен.
Категория «интеграция» живёт только в манифестном type
(integration/translation/local), кодового маркера-категории нет.
Перечисление установленных плагинов делается из манифестов, а не из
маркер-интерфейса.
use AnimeDb\PluginContracts\ExternalIdResolutionInterface; class MySourcePlugin implements ExternalIdResolutionInterface { public function resolveExternalId(array $urls): ?string { foreach ($urls as $url) { if (preg_match('#^https://my-source\.example/anime/(\d+)$#', $url, $m)) { return $m[1]; } } return null; } }
Плагин получает список всех внешних ссылок, уже привязанных к записи
каталога, сам разбирает их по паттерну URL своего вендора и возвращает
найденный id или null, если среди ссылок нет «своей». Хост-приложению не
нужно знать, какая ссылка какому плагину принадлежит — это делегировано
плагину.
SearchByPluginInterface
Реализуется плагинами, которые умеют искать/сопоставлять тайтл с внешним источником.
use AnimeDb\PluginContracts\Search\SearchByPluginCandidate; use AnimeDb\PluginContracts\Search\SearchByPluginInterface; class MySourcePlugin implements SearchByPluginInterface { // Собственный, известный только этому плагину id — из его manifest.json, // не из контракта: ExternalIdResolutionInterface такого метода не предоставляет. private const ID = 'my-vendor-my-source'; public function resolveExternalId(array $urls): ?string { // ... } public function find(string $name, ?callable $onHeartbeat = null): array { $candidates = []; foreach ($this->fetchPages($name) as $page) { // Let the caller know the search is still progressing, e.g. to // refresh a background job lock. Safe to skip if not given. if ($onHeartbeat !== null) { $onHeartbeat(); } foreach ($page as $match) { $candidates[] = new SearchByPluginCandidate( pluginId: self::ID, name: $match->title, externalId: $match->id, ); } } return $candidates; } }
SearchByPluginCandidate
Одно совпадение поиска. Намеренно лёгкий класс, чтобы его было дёшево создавать в большом количестве, пока плагин обходит крупный внешний каталог:
getPluginId()— id плагина, который создал кандидата (значение задаёт сам плагин/вызывающая сторона — контракт не диктует, откуда оно берётся).getName()— найденное название, как оно указано во внешнем источнике.getExternalId()— внешний id найденной записи, чтобы вызывающая сторона могла выполнить последующийfindById()без дополнительного поиска.
$onHeartbeat
find() принимает опциональный heartbeat-колбэк типа callable(): void.
Плагин может вызывать его между внутренними шагами (страницы пагинации,
повторные попытки) долгого поиска; делать это не обязательно, а вызывающая
сторона, которой это не нужно, просто не передаёт аргумент (null).
FillerInterface
Расширяет SearchByPluginInterface — плагин, умеющий заполнять карточку,
обязан уметь и искать (find() переиспользуется как есть, отдельного
метода поиска с полными объектами нет).
use AnimeDb\PluginContracts\Filler\FillerInterface; use AnimeDb\PluginContracts\Filler\PluginAnimeData; class MySourcePlugin implements FillerInterface { // ... find(), resolveExternalId() как у SearchByPluginInterface public function findById(string $externalId): ?PluginAnimeData { $data = $this->fetchDetails($externalId); return $data === null ? null : new PluginAnimeData( title: $data->title, genres: $this->mapGenres($data->genres), episodesCount: $data->episodes, // остальные поля — по мере доступности у источника ); } public function getFillableFields(): array { return ['title', 'genres', 'episodesCount']; } }
findById() возвращает полностью заполненный PluginAnimeData по
известному внешнему id — результат кэшируется на стороне вызывающего кода.
Точечное заполнение одного поля — не отдельный метод плагина, а обёртка
хост-приложения над этим же вызовом: плагин не обязан знать про
«заполнение одного поля».
getFillableFields() — список полей PluginAnimeData, которые плагин
реально умеет заполнять; хост использует его, чтобы показать кнопки
«заполнить из источника» только для поддерживаемых полей.
PluginAnimeData
Плоский DTO с данными, заполненными плагином из внешнего источника.
Специально не привязан ни к какой ORM-сущности хост-приложения:
обязательно только title, всё остальное — nullable и может быть не
заполнено плагином, который этого не поддерживает. Закрытые словари
(жанр, тема, демография, тип) — контрактные enum'ы (GenreCode,
ThemeCode, Demographic, AnimeType, см. «Общие примитивы»),
а не произвольные строки, чтобы не терять типобезопасность при развязке с
внутренними enum'ами хост-приложения.
Поля: title, alternativeNames, descriptions, genres, themes,
demographic, studios, type, datePremiere, dateEnd,
durationMinutes, episodesCount, countries, cover, images.
SyncInterface
Синхронизация пользовательских списков между хост-приложением и внешним источником. В отличие от интерактивных Filler/Widget/Search, sync настраивается один раз и работает в фоне — по двум независимым направлениям.
Расширяет FillerInterface: pull() создаёт локальные записи из списка
пользователя на внешнем источнике, а «голая» запись с одним заголовком —
невалидная карточка. Sync-плагин обязан уметь и заполнять карточку
(find(), findById(), getFillableFields()), поэтому sync-плагина без
филлера не существует на уровне контракта.
use AnimeDb\PluginContracts\Filler\PluginAnimeData; use AnimeDb\PluginContracts\Search\SearchByPluginCandidate; use AnimeDb\PluginContracts\Sync\SyncInterface; use AnimeDb\PluginContracts\Sync\SyncItem; use AnimeDb\PluginContracts\Sync\SyncStatus; class MySourcePlugin implements SyncInterface { // ... find(), findById(), getFillableFields() как у FillerInterface public function push(SyncItem $item): SyncItem { // отправить изменение статуса на внешний источник и вернуть // подтверждённое источником состояние записи (с его updatedAt) return $item; } public function pull(): iterable { foreach ($this->fetchUserList() as $entry) { yield new SyncItem( externalId: $entry->id, status: SyncStatus::Watching, // нормализовано из словаря источника title: $entry->title, ); } } }
SyncStatus — закрытый словарь статусов просмотра, общий для
хост-приложения и всех sync-плагинов: Plan, Watching, Completed,
Dropped, OnHold. Плагин нормализует собственный словарь источника
(например, "watching"/"completed" MAL) в этот enum один раз, в своём
адаптере; хост-приложение затем сопоставляет его со своим внутренним
представлением статуса.
CatalogWidgetInterface и EntryWidgetInterface
Два отдельных интерфейса для двух разных контекстов размещения виджета — с разным входом, поэтому не один интерфейс с опциональным параметром:
use AnimeDb\PluginContracts\Widget\CatalogWidgetInterface; use AnimeDb\PluginContracts\Widget\EntryWidgetInterface; use AnimeDb\PluginContracts\Widget\WidgetMetadata; // Виджет на общей странице каталога (например, "новинки") — без контекста. class NewReleasesWidget implements CatalogWidgetInterface { public static function metadata(): WidgetMetadata { return new WidgetMetadata('new-releases', 'widget.new-releases.title', 'widget.new-releases.description'); } public function render(): string { return '<div class="new-releases">...</div>'; } } // Виджет на странице отдельной записи каталога — получает AnimeId записи, // а не внешний id: состояние записи, включая внешний id, при необходимости // читается через CatalogReaderInterface. class RelatedTitlesWidget implements EntryWidgetInterface { public static function metadata(): WidgetMetadata { return new WidgetMetadata('related-titles', 'widget.related-titles.title', 'widget.related-titles.description'); } public function render(AnimeId $anime): string { return '<div class="related-titles">...</div>'; } }
Плагин может объявлять несколько виджетов — по одному классу на виджет, каждый переключается независимо в UI хост-приложения. Вывод — голая HTML-строка, не структурированные данные: это покрывает и случаи, когда виджет — не список записей каталога. Визуальная консистентность для частого случая «список записей» — опциональный хелпер на стороне хост-приложения, не жёсткая схема в этом контракте.
EntryWidgetInterface::render() получает AnimeId записи, а не внешний
id: многим виджетам он вообще не нужен (например, виджету статуса закачки
достаточно AnimeId, чтобы прочитать свой срез). Виджету, которому нужен
внешний id, доступен уже резолвнутый хостом AnimeView::$externalId через
CatalogReaderInterface. Виджет,
которому нужен собственный внешний id сверх этого, реализует
ExternalIdResolutionInterface дополнительно и явно — ни
CatalogWidgetInterface, ни EntryWidgetInterface его не наследуют.
metadata() — статический метод, возвращающий WidgetMetadata: код-имя
(name, шаблон [a-z0-9-]+ — им хост ключует виджет как
{pluginId}:{name} в DI-теге/URL/features, менять после релиза нельзя
без миграции), titleKey и descriptionKey для страницы настроек хоста.
Статик — чтобы хост-TagPluginServicesPass мог прочитать name при
компиляции контейнера без инстанцирования класса виджета; titleKey/
descriptionKey — ключи перевода, которые хост резолвит своим
Translator'ом в домене перевода плагина (домен = id плагина) в текущей
UI-локали — плагин обязан поставить строки для этих ключей в своём
translations/ как минимум для дефолтной локали.
DownloadCandidateSearchInterface
Интерактивный пользовательский поиск скачиваемых кандидатов по внешнему
источнику — отдельная функция от SearchByPluginInterface. Разница:
SearchByPluginInterface — лёгкое распознавание тайтла при сканировании
тысяч папок (SearchByPluginCandidate: id плагина + название + внешний
id). DownloadCandidateSearchInterface — по явному запросу пользователя,
возвращает богатые элементы для показа в UI и постановки действий над
ними (в т.ч. «скачать»).
use AnimeDb\PluginContracts\CandidateSearch\AnimeSearchResult; use AnimeDb\PluginContracts\CandidateSearch\AnimeSearchResultAction; use AnimeDb\PluginContracts\CandidateSearch\AnimeSearchResultItem; use AnimeDb\PluginContracts\CandidateSearch\DownloadCandidateSearchInterface; use AnimeDb\PluginContracts\Download\DownloadServiceInterface; use AnimeDb\PluginContracts\Download\DownloadSource; use AnimeDb\PluginContracts\Model\AnimeId; class ExampleDownloadSearchPlugin implements DownloadCandidateSearchInterface { public function __construct( private readonly DownloadServiceInterface $downloads, ) { } public function search(string $query): AnimeSearchResult { $items = []; foreach ($this->fetchResults($query) as $candidate) { $items[] = new AnimeSearchResultItem( title: $candidate->title, externalId: $candidate->id, // сводит кандидата к записи каталога image: $candidate->coverBase64, // плагин сам фетчит и уменьшает превью fields: ['quality' => $candidate->quality, 'size' => $candidate->size], actions: [new AnimeSearchResultAction('download', 'Скачать')], meta: $candidate->source, // непрозрачно для ядра, вернётся как есть в runAction() ); } return new AnimeSearchResult($items); } public function runAction(string $actionId, string $meta, AnimeId $anime): void { if ($actionId === 'download') { $this->downloads->enqueue(DownloadSource::magnet($meta), $anime); } } }
AnimeSearchResult / AnimeSearchResultItem / AnimeSearchResultAction
Нейтральные, аниме-типизированные DTO без семантики конкретного источника:
AnimeSearchResult::$items— списокAnimeSearchResultItem.AnimeSearchResultItem—title,externalId(id кандидата на источнике, по которому кандидат сводится к записи каталога — новой или уже существующей; если у источника нет канонического id, крайний фолбэк — стабильный хеш от лежащей в основе ссылки на закачку / специфичного для источника идентификатора, так что один и тот же кандидат всегда сводится к одному и тому же id),image(превью-обложка в base64 илиnull, плагин фетчит и уменьшает её сам — клиент не ходит во внешнюю сеть напрямую),fields(произвольные визуализируемые доп. поля,label => value),actions(списокAnimeSearchResultAction),meta(непрозрачная для ядра строка, специфичная для плагина).AnimeSearchResultAction—idдействия иlabelдля показа пользователю.
meta ядро не интерпретирует — только переносит и возвращает плагину
как есть при вызове runAction(). Всё специфичное для источника (как
понимать fields, что делает конкретное действие) — на стороне плагина.
Про защиту meta: DTO уходит на клиента (в т.ч. мобильного) и
возвращается обратно при runAction(). Ядро оборачивает meta в
подписанный конверт (HMAC на секрете приложения) на отдаче и верифицирует
на возврате — защита от подмены на клиенте. Это ответственность
хост-приложения; на уровне контракта meta — просто строка.
SettingsPageInterface
Собственная страница настроек плагина, встраиваемая в settings-область
хост-приложения (модель Chrome options_ui). В отличие от виджетов,
не наследует ExternalIdResolutionInterface: страница настроек не
привязана к конкретной записи каталога, резолв внешнего id ей не нужен.
use AnimeDb\PluginContracts\Settings\SettingsPageInterface; use AnimeDb\PluginContracts\Settings\SettingsStoreInterface; class MySourceSettingsPage implements SettingsPageInterface { public function __construct( private readonly SettingsStoreInterface $settings, private readonly \Twig\Environment $twig, ) { } public function render(): string { return $this->twig->render('@my-source/settings.html.twig', [ 'settings' => $this->settings->read(), ]); } }
Контракт покрывает только рендер. Вся интерактивность — сохранение формы,
кнопка «Авторизоваться», OAuth redirect/callback — идёт через собственные
роуты плагина (plugin-routing.yaml); render() отдаёт HTMX-форму,
целящуюся в эти роуты. Ядро OAuth за плагин не реализует; плагину со
стандартным Authorization Code + PKCE флоу не нужно реализовывать его
самому — см. OAuth\AbstractOAuthClient ниже.
Кнопка «Авторизоваться» — обычная top-level навигация (ссылка/сабмит формы с перезагрузкой страницы), не HTMX-swap: Electron-обвязка хоста перехватывает OAuth-редирект браузера только на настоящей top-level навигации, HTMX-запрос она не увидит, и кнопка молча не сработает.
Обвязка для реализаторов: плагин получает SettingsStoreInterface и
прочие host-сервисы через DI и рендерит через host-Twig (локаль и
csrf_token() там ambient). Хост оборачивает вызов render() в
try/catch, чтобы одна сломанная страница не роняла всю settings-область.
Ровно одна страница настроек на плагин.
Что предоставляет ядро
Сервисы, которые хост-приложение инжектирует в конструктор плагина (type-hint интерфейса в конструкторе — реализация целиком на стороне хост-приложения).
CatalogReaderInterface и AnimeView
Read-only проекция текущего состояния записи каталога — сервис ядра,
даваемый плагину через DI, как LlmServiceInterface и
DownloadServiceInterface. Нужен, когда плагину требуются общие поля
записи, а не только свой срез: виджету — отрендерить карточку по AnimeId,
донасыщению после закачки — список эпизодов и т.п.
use AnimeDb\PluginContracts\Catalog\AnimeView; use AnimeDb\PluginContracts\Catalog\CatalogReaderInterface; use AnimeDb\PluginContracts\Model\AnimeId; use AnimeDb\PluginContracts\Widget\EntryWidgetInterface; use AnimeDb\PluginContracts\Widget\WidgetMetadata; class RelatedTitlesWidget implements EntryWidgetInterface { public function __construct( private readonly CatalogReaderInterface $catalog, ) { } public static function metadata(): WidgetMetadata { return new WidgetMetadata('related-titles', 'widget.related-titles.title', 'widget.related-titles.description'); } public function resolveExternalId(array $urls): ?string { // ... } public function render(AnimeId $anime): string { $view = $this->catalog->read($anime); if ($view === null) { return ''; } // $view->externalId уже резолвлен хостом для этого плагина — // не нужно самому парсить $view->sources на каждый HTMX-рендер. return \sprintf('<div class="related-titles">%s</div>', $view->title); } }
AnimeView — плоский иммутабельный DTO: title, alternativeNames,
type, genres, themes, episodesCount, sources (внешние ссылки,
уже привязанные к записи) и externalId — собственный внешний id
вызывающего плагина, дешёво резолвнутый хостом заранее (lookup по
таблице external_id, а не парсинг sources на каждый вызов). В отличие
от списочных полей PluginAnimeData, где null означает «плагин-источник
это поле не заполнил», списочные поля AnimeView не бывают null —
только пустой массив, если ничего не известно: это уже смёрженное
текущее состояние, а не вклад одного источника.
Не путать с PluginAnimeData — тот DTO «на запись» из источника, этот —
«на чтение» уже смёрженного состояния каталога. Только чтение: в этом
интерфейсе нет и не будет метода записи — мутации своего среза плагина и
мутации записи целиком остаются задачей других частей контракта, не этой.
LlmServiceInterface
Сервис ядра, дающий плагину доступ к локальной LLM. Мотив: некоторые источники отдают метаданные свободным человеческим текстом (например, описание релиза на форуме, где каждый автор пишет как хочет), и парсер такого текста в структурированные данные должен жить в ядре одним экземпляром, а не дублироваться в каждом плагине.
use AnimeDb\PluginContracts\Llm\LlmServiceInterface; class MyForumFillerPlugin { public function __construct( private readonly LlmServiceInterface $llm, ) { } private function parseReleasePost(string $rawText): array { $prompt = "Верни JSON с полями title, episodesCount, genres. Текст поста:\n".$rawText; return $this->llm->parse($prompt); } }
Плагин сам формирует промпт (в том числе явно просит вернуть JSON), ядро
прогоняет локальную модель, дополнительно подкрепляет JSON-режим своим
системным промптом и декодит ответ в ассоциативный массив. Плагин получает
сервис через DI по тому же принципу, что и PSR-18 HTTP-клиент (см. ниже):
type-hint интерфейса в конструкторе — реализация целиком на стороне
хост-приложения. Плагин, которому нужен этот сервис, декларирует это в
manifest.json флагом features.llm (см. раздел «Манифест плагина»).
Реализация всегда обращается к модели по HTTP через PSR-18 клиент, поэтому
parse() может бросить исключения трёх категорий (виды исключений внутри
ClientExceptionInterface, например NetworkExceptionInterface и
RequestExceptionInterface, определяет сам PSR-18):
use AnimeDb\PluginContracts\Llm\LlmDisabledException; use Psr\Http\Client\ClientExceptionInterface; try { $data = $this->llm->parse($prompt); } catch (LlmDisabledException $exception) { // LLM выключен в настройках хост-приложения — плагин продолжает // работу без LLM-обогащения, это не сбой. } catch (ClientExceptionInterface $exception) { // сбой транспорта: сеть отвалилась, таймаут, 5xx от бэкенда. } catch (\JsonException $exception) { // ответ модели не удалось декодировать как JSON. }
PluginDataStoreInterface
Сервис ядра для чтения/записи собственного payload плагина по AnimeId —
для плагин-инициированных записей, которые не проходят через filler-флоу
(там плагин просто возвращает PluginAnimeData, а маппинг во внутреннее
представление и сохранение делает ядро). Пример: обработчик
DownloadCompletedEvent донасыщает карточку данными из скачанных файлов —
у него на руках AnimeId, но нет способа ни прочитать, ни записать
что-либо без этого сервиса.
use AnimeDb\PluginContracts\Download\DownloadCompletedEvent; use AnimeDb\PluginContracts\Model\AnimeId; use AnimeDb\PluginContracts\PluginData\PluginDataStoreInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; class ExampleDownloadPlugin implements EventSubscriberInterface { public function __construct( private readonly PluginDataStoreInterface $store, ) { } public static function getSubscribedEvents(): array { return [DownloadCompletedEvent::class => 'onDownloadCompleted']; } public function onDownloadCompleted(DownloadCompletedEvent $event): void { $known = $this->store->read($event->anime); // ... разобрать скачанные файлы, дополнить $known своими полями $this->store->write($event->anime, $known); } }
read() возвращает пустой массив, если для этого плагина и этой записи
ещё ничего не сохранено. write() — это override, а не merge: ключи,
записанные предыдущим вызовом и отсутствующие в переданных данных,
удаляются. Так плагин может явно очистить часть своего среза.
Экземпляр, который получает плагин через DI, скоупнут на сам плагин — плагин физически не может прочитать или перезаписать срез другого плагина, потому что метод не принимает id плагина: экземпляр уже знает свой.
В контракте намеренно нет flush() — это была бы протечка реализации.
write() выражает намерение «сохрани мои данные»; как и когда это
персистится (write-through в БД, батчинг и т.п.) — забота реализации
сервиса на стороне хост-приложения, не контракта.
SettingsStoreInterface
Сервис ядра для чтения/записи собственных настроек плагина — конфигурации
и токенов/секретов, которые плагин получает в ходе своего OAuth-флоу.
Параллель PluginDataStoreInterface, но без AnimeId: настройки —
per-плагин, а не per-запись каталога.
use AnimeDb\PluginContracts\Settings\SettingsStoreInterface; class MySourceSettingsPage { public function __construct( private readonly SettingsStoreInterface $settings, ) { } public function saveApiToken(string $token): void { $current = $this->settings->read(); $this->settings->write([...$current, 'apiToken' => $token]); } public function revokeApiToken(): void { $current = $this->settings->read(); unset($current['apiToken']); $this->settings->write($current); } }
read() возвращает пустой массив, если для этого плагина ещё ничего не
сохранено. write() — это override, а не merge: заменяет весь payload
плагина целиком, ключи, отсутствующие в переданных данных, удаляются.
Так плагин отзывает OAuth-токен или чистит поле — просто не передавая его
ключ в очередном write().
Как и PluginDataStoreInterface, экземпляр скоупнут на сам плагин (id
плагина не в сигнатурах) и не имеет flush() — по тем же причинам.
Скоупинг per-id — это изоляция от коллизий ключей между плагинами, не security-граница: плагин — доверенный код после установки, исполняющий произвольный PHP в процессе хоста, это нормально by design. Секреты в хранилище защищены на уровне реализации хост-приложения (шифрование на диске и т.п.), а не на уровне этого контракта.
DownloadServiceInterface
Сервис ядра для постановки задачи на скачивание. Плагин не работает с
менеджером загрузок и очередью напрямую — только просит поставить задачу
и получает DownloadTaskId, который сохраняет в свой срез метаданных
аниме (закачка идёт долго, и DownloadCompletedEvent может прийти уже
после перезапуска приложения, поэтому хранить id только в памяти нельзя).
use AnimeDb\PluginContracts\Download\DownloadCompletedEvent; use AnimeDb\PluginContracts\Download\DownloadServiceInterface; use AnimeDb\PluginContracts\Download\DownloadSource; use AnimeDb\PluginContracts\Model\AnimeId; use Symfony\Component\EventDispatcher\EventSubscriberInterface; class ExampleDownloadPlugin implements EventSubscriberInterface { public function __construct( private readonly DownloadServiceInterface $downloads, ) { } public function startDownload(string $magnetUri, AnimeId $anime): void { $taskId = $this->downloads->enqueue(DownloadSource::magnet($magnetUri), $anime); // сохранить $taskId в собственный срез метаданных $anime } public static function getSubscribedEvents(): array { return [DownloadCompletedEvent::class => 'onDownloadCompleted']; } public function onDownloadCompleted(DownloadCompletedEvent $event): void { // сопоставить $event->task с сохранённым ранее id; // если совпало — обогатить карточку из скачанных файлов // (тяжёлую работу увести в свою фоновую задачу) } }
DownloadSource
Именованные конструкторы с самовалидацией — они же документируют, что вообще поддерживается:
DownloadSource::magnet(string $uri): self— валидирует формуmagnet:?xt=urn:btih:....DownloadSource::torrentFile(string $path): self— валидирует, что путь оканчивается на.torrent.
Обе выбрасывают \InvalidArgumentException при некорректном значении.
Набор расширяемый — например, ::url() в будущем — без изменения уже
написанных плагинов.
DownloadTaskId / DownloadCompletedEvent
DownloadTaskId — id поставленной задачи, возвращаемый enqueue().
DownloadCompletedEvent — событие завершения закачки ($anime, $task),
на которое плагин подписывается штатным Symfony EventSubscriberInterface:
это обычный класс, без привязки к базовому классу события Symfony —
диспетчеру достаточно имени класса, чтобы разослать событие подписчикам.
PSR-18 HTTP-клиент
Плагины не создают HTTP-клиент сами — получают преднастроенный
PSR-18 Psr\Http\Client\ClientInterface через DI (type-hint в
конструкторе). Это позволяет включать прокси централизованно, в фабрике
клиента на стороне хост-приложения, прозрачно для плагинов. Отдельного
интерфейса в этом пакете для этого не заводится — соглашение, не
контрактный тип. Тот же клиент — с per-plugin User-Agent (некоторые
вендоры, например Shikimori, банят запросы без него) — используется и
OAuth\AbstractOAuthClient ниже для обмена/обновления токена.
OAuth\AbstractOAuthClient
Тонкий абстрактный базовый класс OAuth 2.0 Authorization Code + PKCE для
плагинов-источников (Shikimori, MyAnimeList и т.п.), чтобы каждый плагин не
переизобретал PKCE/state/обмен кода на токен заново. Живёт в этом пакете,
а не в отдельной библиотеке (league/oauth2-client и подобные), потому что
плагин поставляется без своего vendor/ — всё, что ему нужно в
рантайме, должно быть доступно из plugin-contracts. Построен на PSR-18
клиенте и PSR-17 фабриках запросов/потоков (psr/http-factory), без внешних
OAuth-зависимостей.
use AnimeDb\PluginContracts\OAuth\AbstractOAuthClient; final class ShikimoriOAuthClient extends AbstractOAuthClient { protected function authorizeEndpoint(): string { return 'https://shikimori.one/oauth/authorize'; } protected function tokenEndpoint(): string { return 'https://shikimori.one/oauth/token'; } protected function clientId(): string { return 'shikimori-client-id'; } protected function clientSecret(): ?string { return 'shikimori-client-secret'; } protected function scopes(): array { return []; } protected function pkceMethod(): string { return 'S256'; } protected function callbackPath(): string { return '/oauth/shikimori/callback'; } }
Плагин подключает эти два метода к своим собственным роутам:
// GET /oauth/shikimori/start — top-level навигация, не HTMX public function start(): RedirectResponse { return new RedirectResponse($this->oauth->buildAuthorizeUrl()); } // GET /oauth/shikimori/callback?state=...&code=... public function callback(Request $request): Response { $this->oauth->handleCallback($request->query->get('state'), $request->query->get('code')); return new Response('Авторизация завершена, вкладку можно закрыть.'); }
Ни один из методов класса не принимает и не возвращает
Symfony-HTTP-типы (Request/Response) — это чистая логика (строки,
PSR-18, PSR-17, SettingsStoreInterface), иначе symfony/http-foundation
стал бы зависимостью контракта. HTTP-обвязку поверх неё пишет сам плагин.
Что берёт на себя базовый класс:
- сборку authorize-URL (
redirect_uri,client_id,scope,state, PKCEcode_challenge) и генерациюstate/PKCE code verifier, с сохранением обоих вSettingsStoreInterfaceна время сессии (buildAuthorizeUrl()); - обмен
code+ verifier на токен и обязательную сверкуstateчерезhash_equals()— это то, что делает безопасным неаутентифицированный loopback callback (handleCallback()); - обновление access-токена по refresh-токену, с ротацией: новый
refresh-токен пишется в
SettingsStoreInterfaceотдельным вызовомwrite()до того, как в настройки попадёт новый access-токен — крах процесса между «потратил старый refresh» и «записал новый» не теряет токен (refreshAccessToken()).
authorizeEndpoint()/tokenEndpoint() — вендорные домены, прибитые
гвоздями в подклассе, а не выведенные из пользовательского
api_endpoint: иначе смена домена пользователем (соц. инженерия) уводит
долгоживущий refresh-токен на чужой сервер при плановом обновлении.
Настраиваемым остаётся только data api_endpoint, не auth-эндпоинты.
redirect_uri собирается как $_SERVER['OAUTH_CALLBACK_ORIGIN'] (задаёт
хост-приложение; читается из $_SERVER, не getenv()) плюс
callbackPath() подкласса. Ожидается, что origin — уже http://127.0.0.1:<port>
(буквальный loopback-адрес, RFC 8252 §8.3, не localhost) без trailing
slash; класс не хардкодит порт и не резолвит хост сам, только
конкатенирует.
Плагин с нестандартным OAuth-флоу этот класс не использует вообще (escape hatch) — реализует свои роуты сам.
Общие примитивы
Namespace AnimeDb\PluginContracts\Model\ — общие для всех фич value-объекты
и закрытые словари (enum'ы).
AnimeId — id записи каталога, как он известен хост-приложению. Тонкий
value-объект, а не голый int: используется и стороной плагина
(EntryWidgetInterface::render(), PluginDataStoreInterface,
CatalogReaderInterface::read()), и сервисами ядра (DownloadServiceInterface,
DownloadCompletedEvent), поэтому он не привязан ни к одному конкретному
namespace фичи.
Закрытые словари (enum'ы). Значения синхронизированы 1:1 со словарями MyAnimeList; пакет не зависит от внутренних enum'ов хост-приложения — сопоставление на его стороне.
AnimeType— тип тайтла:Tv,Movie,Ova,Ona,Special,Music.Demographic— демографическая ось MAL:Shounen,Shoujo,Seinen,Josei,Kids.GenreCode— ось жанров MAL (18 значений, напримерAction,Comedy,Fantasy,SliceOfLife) — список может расширяться минорными версиями.ThemeCode— ось тем MAL (51 значение, напримерIsekai,Mecha,School,TimeTravel) — список может расширяться минорными версиями.
Манифест плагина (manifest.json)
Пакет также содержит общий парсер и валидатор файла manifest.json,
который каждый плагин несёт в корне своего ZIP-архива. Компонент нужен,
чтобы не дублировать логику разбора и проверки манифеста в двух местах:
CI реестра маркета (перед приёмом плагина в статический реестр) и
клиентский инсталлятор хост-приложения (перед установкой кастомного ZIP
из недоверенного источника).
{
"id": "vendor-shikimori",
"name": "Shikimori",
"version": "1.0.0",
"description": "Описание плагина",
"author": "Vendor Name",
"type": "integration",
"features": {"filler": true, "related_widget": true, "sync": true, "llm": true},
"require": {
"core": ">=2.0.0",
"php": ">=8.2",
"plugin-contracts": "^2.0"
},
"update_url": "https://example.com/plugins/registry.json",
"ui": {
"css": ["assets/carousel.css"],
"js": ["assets/settings.js"]
}
}
use AnimeDb\PluginContracts\Manifest\InvalidManifestException; use AnimeDb\PluginContracts\Manifest\InvalidManifestJsonException; use AnimeDb\PluginContracts\Manifest\ManifestParser; $parser = new ManifestParser(); try { // parse() сам декодирует и валидирует содержимое — отдельно вызывать // decode()/validate() перед ним не нужно. $manifest = $parser->parse($rawManifestJson); } catch (InvalidManifestJsonException $exception) { // $rawManifestJson — не валидный JSON или JSON не-объект на верхнем уровне. return; } catch (InvalidManifestException $exception) { foreach ($exception->errors as $error) { // $error->field — dot-path, например "require.core" или "features.filler" // $error->message — человекочитаемая причина printf("%s: %s\n", $error->field, $error->message); } return; }
decode() и ManifestValidator::validate() остаются публичными отдельно от
parse() — для потребителей (например, UI клиентского инсталлятора),
которым нужен полный список ошибок валидации ещё до того, как решать,
вызывать ли parse() вообще:
use AnimeDb\PluginContracts\Manifest\ManifestValidator; $data = $parser->decode($rawManifestJson); // бросает InvalidManifestJsonException $errors = (new ManifestValidator())->validate($data);
Обязательные поля манифеста — id, name, version, type. type —
закрытый словарь (PluginType::Integration, PluginType::Translation или
PluginType::Local): обычный код-плагин, интегрирующийся с внешним
источником, объявляет features (плоский набор булевых флагов, среди
которых — роли filler/sync/search и имена виджетов); код-плагин
local — тот, чей код не обращается ни к какому внешнему источнику ни
напрямую, ни через предоставленные хостом абстракции, — тоже может
объявлять features, но только с именами виджетов: ключи filler, sync
и search в нём отвергаются валидатором как роли интеграционного плагина;
чисто декларативный ресурс переводов translation (набор файлов локалей,
без кода) features не объявляет вовсе.
locales — языки каталогов переводов, которые везёт плагин, — доступен
всем трём типам: обязателен и непуст для translation (как единственного
источника, из которого приложение узнаёт список локалей, расширяющих
переключатель языка интерфейса), опционален для integration и local
(каталоги переводов этих плагинов живут в собственном домене
<plugin-id>.<locale>.yaml и переключатель языка не расширяют). Каждый
элемент — голый языковой субтег (две или три строчные латинские буквы),
без региона и письменности: pt-BR, zh-Hans и ru_RU отклоняются.
Трёхбуквенный код допустим только для языков, у которых нет
двухбуквенного кода ISO 639-1 (например, fil — филиппинский); если
двухбуквенный код существует, использовать нужно его — например, для
казахского это kk, а не kaz.
Пакет не проверяет конкретные ключи features (кроме того, что значения —
булевы) — это открытый набор флагов по соглашению между плагином и хостом.
Флаг llm: true — плагин декларирует, что ему нужен LlmServiceInterface.
require.core и require.php — только нижняя граница версии (>=X.Y.Z,
без верхней границы: верхнюю границу совместимости определяет отдельный
эмпирический механизм реестра, не сам манифест). require.plugin-contracts
— опциональный, более точный сигнал совместимости, чем версия ядра целиком,
и допускает любой валидный семвер-констрейнт (например, ^2.0). Синтаксис
всех трёх констрейнтов проверяется через composer/semver.
translation_keys_count — поле реестра маркета: число ключей каталога
переводов плагина, обязательное для манифестов типа translation.
Валидируется тулингом монорепозитория плагинов (сверкой с фактическим
содержимым translations/), этим пакетом не проверяется и в Manifest
не попадает — см. «Незнакомые поля манифеста» ниже.
ui — стили и скрипты плагина
ui — опциональное поле: списки путей к CSS- и JS-файлам, которые хост сам
подключает в шелл страницы при отрисовке UI плагина (настроек, виджетов).
Это штатный и проверяемый способ поставлять собственные стили и скрипты —
не единственный (плагин может отдать свой .js/.css и собственным
контроллером через plugin-routing.yaml), но инлайн-разметка для этого не
годится: она несовместима с Content-Security-Policy хост-приложения и
должна вырезаться его HTML-санитайзером.
"ui": { "css": ["assets/carousel.css"], "js": ["assets/settings.js"] }
Правила для каждого пути в ui.css/ui.js:
- путь начинается с
assets/— это единственный каталог плагина, который хост подключает сам; - путь относительный: без ведущего
/, без\,:и сегмента..; - расширение соответствует списку в нижнем регистре:
.cssдляcss,.jsдляjs(assets/style.CSSотклоняется); - без повторов внутри одного списка.
ui, если присутствует, — объект строго с ключами css и/или js (любой
другой ключ отклоняется — опечатка вроде styles иначе молча означала бы
«стилей нет») и хотя бы одним непустым списком. Поле не допускается для
type: translation — этот тип не везёт кода и не рисует свой UI.
Пакет проверяет только форму пути — существование файла по этому пути
внутри архива плагина не проверяется (ManifestValidator — чистая функция
от декодированного JSON, без доступа к файловой системе); это отдельно
делают тулинг монорепозитория плагинов на публикации и установщик
хост-приложения. Изображения в ui не объявляются — плагин подключает их
из своей разметки и своего CSS.
Старые хосты про поле ui не знают и молча его проигнорируют — плагин,
которому его CSS/JS нужны для работы, установится и отрисуется сломанным.
Поэтому плагин с ui обязан пинить "plugin-contracts": "^0.19" в
require.plugin-contracts.
Незнакомые поля манифеста
ManifestValidator::validate() намеренно не отвергает поля манифеста,
которых не знает — он отвергает только известные-но-неуместные для данного
type (например, features целиком у translation, или ключи ролей
filler/sync/search внутри features у local), а не любой ключ
вне фиксированного списка. Причина: схема manifest.json
расширяется потребителями за пределами этого контракта. Пример —
translation_keys_count (см. выше): поле нужно тулингу монорепозитория
плагинов и реестру маркета. Хост-приложение значение тоже использует
(показывает на витрине маркета полноту перевода до установки плагина), но
берёт его из записи версии в plugins-registry.json, а не из манифеста —
поэтому в Manifest DTO этого пакета оно не заводится и релизить ради него
контракт незачем: манифест с ним просто проходит валидацию как есть.
Исключение — вложенные ключи объекта ui: там разрешены строго css и
js, любой другой ключ (например, опечатка styles вместо css)
отклоняется, а не проходит как неизвестное поле. Иначе опечатка в имени
ключа молча означала бы «плагин стилей/скриптов не объявляет», а не
ошибку в манифесте.
PHPStan-правила
Пакет поставляет набор правил PHPStan для проверки кода плагина на
соответствие контракту: запрет опасных сетевых/процессных примитивов,
соответствие сигнатур методов установленной версии интерфейсов пакета,
изоляция кода плагина типа local от сети. Правила подключаются
потребителем к собственному прогону PHPStan по дереву исходников
плагина — так одна и та же логика проверки не дублируется в каждом
потребителе по отдельности.
Пакет не тянет phpstan/phpstan как рантайм-зависимость — PHPStan нужен
только тем, кто реально подключает эти правила. Чтобы включить их, в своём
проекте установите phpstan/phpstan и phpstan/extension-installer;
после этого правила подключатся автоматически, как только anime-db/plugin-contracts
окажется среди зависимостей проекта (в require или require-dev).
Без extension-installer — добавьте extension.neon пакета в свой phpstan.neon вручную:
includes: - vendor/anime-db/plugin-contracts/extension.neon
NoDangerousPrimitivesRule
Запрещает плагину напрямую вызывать низкоуровневые примитивы, которые должны идти через абстракции хост-приложения:
- запуск процессов и сырых сетевых сокетов — всегда, независимо от
аргументов:
exec,shell_exec,passthru,system,popen,proc_open,proc_close,pcntl_exec,fsockopen,pfsockopen,stream_socket_client,stream_socket_server,dns_get_record,gethostbyname, любые функцииcurl_*; - создание известных сетевых клиентов и процесс-лаунчеров через
new— всегда, независимо от аргументов конструктора:SoapClient,Symfony\Component\Process\Process, а также любой класс из дерева зависимостей хост-приложения, реализующийSymfony\Contracts\HttpClient\HttpClientInterfaceилиPsr\Http\Client\ClientInterface(например,CurlHttpClient,NativeHttpClient— конкретный клиент создавать самому нельзя, его инжектирует хост). Класс, объявленный в самом анализируемом коде плагина (например, собственный декоратор плагина поверх инжектированного хостом PSR-18 клиента — троттлинг, ретраи, логирование), под этот пункт не попадает: он не зависимость хост-приложения, а реализация ровно той абстракции, к которой отсылает сообщение об ошибке; что он делает внутри — ловят остальные проверки правила. Проверка по типу, а не по буквальному имени класса — подкласс запрещённого класса (class MySoap extends SoapClient {}) пойман так же, как и сам класс; - статический вызов известных фабрик сетевых клиентов — всегда,
независимо от метода:
Symfony\Component\HttpClient\HttpClient,Http\Discovery\Psr18ClientDiscovery,Http\Discovery\HttpClientDiscovery. Тоже по типу — подкласс одной из этих фабрик пойман так же, как и она сама; - чтение/включение/запись файла со статически известным URL (схема
обёртки вида
https://,ftp://и т.п. в первом аргументе, включая случай, когда URL — литеральный префикс строковой конкатенации с динамическим хвостом, например'https://api.example.com/'.$id):file_get_contents,fopen,copy,file,readfile,simplexml_load_file,getimagesize,file_put_contents,include/include_once/require/require_once, а такжеDOMDocument::load()/DOMDocument::loadHTMLFile(). Вызов с локальным путём (например, конфигом или шаблоном плагина) не запрещён — правило целится в скрытый сетевой запрос под видом файловой операции, а не в файловый ввод-вывод как таковой. Полностью динамический URL (например, значение, прочитанное из свойства без литерального префикса схемы) правило не ловит: у него нет статически выводимого признака, отличающего URL от локального пути, а эвристика по имени переменной/свойства ненадёжна в обе стороны; - оператор обратных кавычек (
`command`) иeval; - динамические вызовы через переменные-функции (
$fn(), но не через array-callable вида[$this, 'method']) и переменные-переменные ($$name).
Все проверки на new/статический вызов сопоставляют по типу
(ObjectType::isSuperTypeOf()), а не по буквальному имени класса — но
у этого сопоставления есть граница. Точное совпадение — сам код вызывает
ровно перечисленный класс (new SoapClient(),
Psr18ClientDiscovery::find()) — ловится всегда, даже если пакет,
которому принадлежит этот класс, не установлен в анализируемом проекте.
А вот поймать другой класс через отношение «подкласс»/«реализация»
(class X extends Process {}, class Y implements ClientInterface {})
можно только тогда, когда сам перечисленный класс/интерфейс реально
резолвится в анализируемом проекте — то есть его пакет
(symfony/process, symfony/http-client, psr/http-client и т.п.)
установлен: без этого PHPStan не может построить реальное дерево
наследования и определить, что X/Y — потомок/реализация. Это
касается всех трёх списков (FORBIDDEN_INSTANTIATIONS,
FORBIDDEN_ABSTRACTION_INSTANTIATIONS, FORBIDDEN_STATIC_CALL_CLASSES),
а не одного из них.
Правило — денилист, а не аллоулист: он перечисляет конкретные функции и классы, а не ограничивает плагин набором разрешённых пространств имён. Денилист по построению отстаёт от дерева зависимостей хост-приложения — новая зависимость хоста, дающая доступ к сетевому/процессному/файловому примитиву, остаётся потенциальным обходом, пока список не расширен под неё. Автоматики, которая обнаруживала бы такой дрейф, нет: при добавлении или обновлении зависимости хост-приложения её нужно вручную проверить на такие примитивы и, если найдены, добавить в соответствующий список выше.
Правило не проверяет, какие сервисы хоста плагин вправе запрашивать через
конструктор (DI) — это отдельная забота, которую оно не покрывает: граница
проходит между «интерфейсами этого пакета контрактов и PSR-интерфейсами,
которые он реэкспортирует» (например, Psr\Http\Client\ClientInterface
для преднастроенного хостом HTTP-клиента) и «конкретными клиентскими
классами из дерева зависимостей хост-приложения» (например,
Symfony\Component\HttpClient\HttpClient, GuzzleHttp\Client,
Symfony\Component\Process\Process). Проверка этой границы требует
разбора деклараций типов конструктора/свойств, а не выражений, и аудита
того, как DI-контейнер хост-приложения связывает сервисы плагина — то и
другое вне области этого правила и вне области пакета, который не
содержит исходников хост-приложения.
Правило не защищает от намеренного обфусцированного обхода — оно поднимает планку для типичного случая и служит автоматическим гейтом перед публикацией плагина в реестре.
ContractConformanceRule
Проверяет, что классы, объявляющие реализацию ExternalIdResolutionInterface
(и всех интерфейсов, которые его расширяют: FillerInterface,
SearchByPluginInterface, SyncInterface), либо CatalogWidgetInterface,
EntryWidgetInterface, DownloadCandidateSearchInterface, имеют
сигнатуры методов, точно совпадающие с сигнатурами из установленной
версии этого пакета. Ловит рассинхронизацию между версией контракта, под
которую написан плагин, и версией, реально установленной у потребителя —
то, что одна успешная компиляция DI-контейнера может не заметить.
NoNetworkAccessInLocalPluginsRule
type: "local" в манифесте объявляет, что код плагина не обращается ни к
какому внешнему источнику — ни напрямую (это уже покрыто
NoDangerousPrimitivesRule выше, который проверяет вызовы и не знает про
типы плагинов), ни через абстракцию, предоставленную хостом. Это правило
проверяет вторую часть: что класс плагина типа local не объявляет в
конструкторе или как свойство тип, дающий доступ к сети.
Правило проверяет декларации типов, а не выражения — этим отличается от
NoDangerousPrimitivesRule. Для этого ему нужно знать тип плагина: он
берётся из manifest.json, ближайшего вверх по дереву каталогов от файла,
который анализируется (сначала проверяется каталог самого файла, затем
каждый родительский). Если по пути раньше встретится манифест вложенного
плагина (например, бандл с примером или тестовые фикстуры своего плагина),
победит именно он — поиск останавливается на первом найденном файле, а не
продолжается в поисках манифеста «получше».
Два разных случая, когда манифест не отвечает на вопрос однозначно:
- манифеста нет вообще нигде выше по дереву — правило молчит для этого
файла. Отсутствие манифеста — забота
ManifestValidator, а не этого правила; это же ожидаемое состояние при анализе исходников самого пакета контрактов (в нём манифеста плагина нет и не будет); - манифест найден, но ему нельзя доверять — не читается, не является
валидным JSON, не проходит проверки
ManifestValidator, не содержит полеtype, либо значениеtypeне распознано установленной версией пакета (например, тип, добавленный более новой версией контракта, чем та, что анализирует код) — в этом случае правило всегда сообщает об ошибке, независимо от того, каким мог бы оказаться тип. Молчание здесь означало бы, что плагин прошёл проверку по причине «не смогли понять, какой он», а не «он не local».
Сетевые типы, которые правило запрещает для local:
Psr\Http\Client\ClientInterface— PSR-18 транспорт: отправка запроса через него и есть сетевой вызов;Psr\Http\Message\RequestFactoryInterface— строит объект запроса, единственное практическое назначение которого — быть переданным вClientInterface.Psr\Http\Message\StreamFactoryInterface(третья PSR-17 фабрика, от которой зависитOAuth\AbstractOAuthClient) в список намеренно не входит — она строит поток в памяти, не более сетевая, чем локальные данные, никакого сетевого смысла сама по себе не несёт;SearchByPluginInterface—find()по определению ищет/сопоставляет тайтл с внешним источником;FillerInterface(расширяетSearchByPluginInterface) —findById()тоже обращается к внешнему источнику за деталями; перечислен в списке явно, вместе с расширяемым интерфейсом, чтобы решение по каждой роли было видно сразу, а не выводилось из цепочки наследования;SyncInterface(расширяетFillerInterface) —push()/pull()синхронизируют состояние с внешним источником;DownloadCandidateSearchInterface—search()— тот же интерактивный поиск по внешнему источнику, что иfind()уSearchByPluginInterface;DownloadServiceInterface—enqueue()запускает скачивание с внешнего сетевого источника (magnet-ссылка или torrent-файл) — собственный пример автора задачи «заведомо сетевого» интерфейса.
Интерфейсы контракта, для которых явно принято обратное решение — «не сетевой»:
ExternalIdResolutionInterface—resolveExternalId()только сопоставляет уже переданный список ссылок, сам вызов сети не делает (интерфейсы выше, которые его расширяют, остаются сетевыми за счёт своих других методов, поэтому перечислены отдельно, а не через наследование от этого интерфейса);CatalogWidgetInterface/EntryWidgetInterface—render()строит разметку из того, что виджету уже доступно; ничего в интерфейсе не требует внешнего источника (виджету статуса закачки или локальных рекомендаций он не нужен вовсе);SettingsPageInterface—render()строит разметку формы настроек; интерактивные шаги, которые могут обращаться к вендору (старт OAuth, обмен токена), идут через собственные роуты плагина, не через этот интерфейс;CatalogReaderInterface— read-only проекция уже смёрженного локального состояния каталога — собственный пример автора задачи «заведомо не сетевого» интерфейса;PluginDataStoreInterface/SettingsStoreInterface— локальные хранилища чтения/записи, скоупнутые на id плагина, внешний источник не участвует;LlmServiceInterface— доступ к локальной LLM хоста. Его реализация действительно обращается к модели по HTTP через PSR-18-клиент, но этот клиент обращается к локальной модели самого хоста, а не в интернет: смысл интерфейса как раз в том, чтобы быть доступным без подключения к сети — ровно то, что обещает вызывающей стороне сам типlocal. ОбъявитьClientInterfaceнапрямую всё равно запрещено — освобождена только эта узкая, более конкретная абстракция.
OAuth\AbstractOAuthClient не входит ни в один из списков: это абстрактный
класс, предназначенный для наследования (extends) собственным
OAuth-клиентом плагина, а не для объявления в виде типа параметра
конструктора или свойства — а extends вне области действия этого правила
(проверяются только объявления типов параметров конструктора и свойств).
Конструктор самого класса уже зависит от ClientInterface и
RequestFactoryInterface — оба входят в список выше, поэтому local-плагин,
инжектирующий через DI собственный подкласс AbstractOAuthClient, всё равно
будет пойман на этом шаге.
Правило включено в стандартный набор по умолчанию, без отдельного флага:
type: "local" в манифесте — это заявление самого автора плагина о том,
что код не обращается к сети ни напрямую, ни через абстракции хоста, а
ручная сверка такого заявления с реальными объявлениями типов не
масштабируется вместе с ростом кодовой базы плагина. Правило сверяет это
заявление автоматически, по декларациям типов конструктора и свойств —
проверка встроена в сам пакет, и потребителю не нужно писать её заново,
достаточно подключить правила к своему прогону PHPStan (см. «PHPStan-правила»
выше).