# CLAUDE.md Конвенции этого репозитория и грабли, на которые здесь уже наступили. Читать до правок. ## Команды Полный локальный гейт (формат → сборка → тесты) — то, что нужно прогнать перед коммитом: ```bash ./build.ps1 ``` Отдельные шаги, если нужен только один из них: ```bash dotnet build AvParser.slnx -c Release ``` ```bash dotnet test AvParser.slnx -c Release ``` ```bash dotnet csharpier check . ``` ```bash ./run.ps1 ``` На Linux/macOS — `./build.sh` и `./run.sh` с теми же шагами. ## Слои `Core ← Infrastructure ← UI ← Desktop`, строго в одну сторону. - **`AvParser.Core` не ссылается на Avalonia.** Это единственное ограничение, которое здесь по-настоящему несущее: домен должен запускаться из CLI, worker-сервиса или бенчмарка. Как только Avalonia станет доступна из домена, кто-нибудь потянется к `Dispatcher.UIThread` или `IStorageProvider` внутри источника. - **`AvParser.UI` — библиотека, а не exe.** Headless-тесты собирают настоящие View, не подтягивая `Program.cs`, Serilog и контейнер. - **`AvParser.Desktop` — тонкий composition root.** Логика туда не переезжает. ## Добавить источник 1. Реализовать `IMediaSource` — в `Core/Collecting/Sources/`, если сети не нужно, иначе в `Infrastructure/Collecting/`. 2. Одна строка регистрации: `AddAvParserCore()` для доменного, `AddAvParserCollecting()` для сетевого. 3. Два ключа в оба resx: `Source.{id}.Name` и `Source.{id}.Description` (без них источник отрисуется английским текстом из самого класса, а не сломается). Всё. Каталог, страница «Сбор» и выпадающий список подхватят его сами. **Источник ищет, а не качает.** Он отдаёт `MediaCandidate` — адрес плюс метаданные. Скачиванием, редиректами, тайм-аутами, сниффингом и троттлингом занимается `MediaFetcher`, один на всех. Источник, который сам лезет за байтами, дублирует всё это и почти наверняка неправильно. **Сетевой источник обязан переопределить `RequiresNetwork => true`** — иначе он не попадёт под гейт и поедет напрямую в обход настройки. ## Добавить страницу 1. Наследник `PageViewModel` в `UI/ViewModels/XxxViewModel.cs` (`Title`, `IconKey`). 2. `UI/Views/XxxView.axaml` — имя обязано соответствовать конвенции `ViewLocator`: `...ViewModels.XxxViewModel` → `...Views.XxxView`. 3. Регистрация в `AddAvParserUI()`: конкретным типом **и** как `PageViewModel` — порядок этих регистраций и есть порядок пунктов в рельсе навигации. ## ReactiveUI 24 (дистрибутив Primitives) Это не классический ReactiveUI. `System.Reactive` не используется: | Классика | Здесь | |---|---| | `Unit` | `RxVoid` | | `IScheduler` | `ISequencer` (`ReactiveUI.Primitives.Concurrency`) | | `Subject` / `BehaviorSubject` | `Signal` / `BehaviorSignal` | | `RxApp.MainThreadScheduler` | `RxSchedulers.MainThreadScheduler` | | `TestScheduler` | `VirtualClock`, `ImmediateSequencer.Instance` | Привычные имена операторов (`Select`, `Where`, `Throttle`, `DistinctUntilChanged`, `CombineLatest`) **работают** — Primitives отдаёт оба набора. `using ReactiveUI.Primitives;` нужен ради `Subscribe(Action)`. **ReactiveUI 24 не инициализируется сама.** Первый `WhenAnyValue` бросит `InvalidOperationException`, пока не отработал builder. В приложении это делает `AppBuilder.UseReactiveUI(...)`; в проекте VM-тестов — module initializer `ReactiveUiBootstrap`. Новый тестовый проект без Avalonia обязан сделать то же самое. ## Конвенции ViewModel - **Каждая VM принимает `ISequencer? mainThread = null`** и использует его в `outputScheduler:` и `ToProperty(..., scheduler)`. Именно это делает тесты синхронными: они передают `ImmediateSequencer.Instance`. Без этого пришлось бы гонять диспетчер. - У VM с необязательным `ISequencer` регистрация в DI — явная фабрика, а не по типу: иначе выбор конструктора контейнером зависит от порядка регистраций. - `[Reactive]` из `ReactiveUI.SourceGenerators` на partial-свойствах; класс — `partial`. ## Добавить источник прокси 1. Реализовать `IProxySource` (или `IMutableProxySource`, если список редактируемый). 2. Зарегистрировать как `IProxySource` в `AddAvParserProxies()`. Порядок регистрации = порядок слияния; свой список идёт последним, чтобы пользовательский адрес перебивал фидовый. `ProxyPool` при обновлении **переиспользует существующие `ProxyEntry`** по `Endpoint.Key` — иначе перезагрузка списка стирала бы всю накопленную статистику, а публичные фиды переиздаются каждые несколько минут. ## Инварианты прокси-пула - **Доступность определяется карантином, а не `Health`.** `Health` — это «что видели в последний раз». Если исключать всё, что когда-либо падало, окно карантина становится бессмысленным, а прокси теряется навсегда после первой же осечки. Это уже был баг, его ловит `A_failing_proxy_is_quarantined_and_comes_back_later`. - **Проба не трогает `SuccessCount`/`FailureCount`.** Эти счётчики про реальные запросы; свип по паре тысяч прокси перезаписал бы всё, на чём держится взвешенный выбор. Провалившаяся проба выставляет карантин через `RecordProbe(..., quarantineOnFailure:)`. - **Лиза без вердикта нейтральна.** Отменённая операция — не вина прокси; считать это отказом значит карантинить здоровые прокси на каждый Cancel. - **`Select` и `Next` — зарезервированные слова для CA1716.** Метод стратегии называется `Pick`. - **`LiveCount` считает только `Alive` и не в карантине.** На нём висит гейт сбора, поэтому «доступна» (карантин истёк) и «живая» здесь намеренно расходятся: гейт не должен открываться от одного лишь истечения окна. - **Прогрев обрывается по достижении цели, а не проходит список до конца.** `WarmUpAsync` линкует CTS и гасит остаток, как только набралось `MinimumLiveProxies`. Порядок кандидатов — `WarmUpOrder()`, он публичный ровно затем, чтобы порядок проверялся без прогона проб. - **`RestoreState` не выставляет `Health = Alive`.** «Работала вчера» живёт в отдельном `WasAliveOnLastRun` и влияет только на порядок прогрева. Если восстанавливать как `Alive`, пул отрапортует живыми тех, с кем не разговаривал: прогрев сочтёт цель достигнутой и не проверит никого, а гейт сбора откроется по данным недельной давности. Ловит `A_remembered_proxy_is_not_reported_live_until_it_answers_again`. - **`RestoreState` не восстанавливает карантин.** Окно — стенные часы, между запусками могли пройти сутки; перенос окна сажал бы прокси за то, что давно истекло. - **Сохраняются только `HasEverAnswered`** = `SuccessCount > 0 || IsBelievedAlive`, где «believed» = вердикт этой сессии, а без него — прошлой. Мёртвые в фиде исчисляются тысячами и переиздаются каждые пять минут. Важен именно перенос: прогрев обрывается рано, поэтому большинство запомненных заканчивают сессию непроверенными — строгое `Health == Alive` стирало бы накопленный список за пару запусков. ## Гейт сбора `IParser.RequiresNetwork` — дефолтная реализация возвращает `false`, поэтому добавление источника остаётся однострочным. Гейт живёт в `CollectViewModel.RefreshProxyGate()` и складывается из трёх условий: источник сетевой, `AllowDirectConnection` выключен, `LiveCount == 0`. Он пересчитывается по событию пула (с throttle 250 мс — пул дёргается на каждый исход лизы), при смене источника и при смене настроек. Второй экземпляр того же правила — в `FetchOptions.RequireProxy`: гейт закрывает кнопку, а фетчер бросает `ProxyUnavailableException` вместо тихого прямого запроса. Одного UI мало — запрос ушёл бы с адреса пользователя ровно тогда, когда он просил этого не делать. `CollectView.axaml` держит баннер под `x:Name="ProxyGateBanner"`; `CollectViewTests` рендерит его по-настоящему, потому что мёртвый биндинг `IsVisible` не ломает ни одного VM-теста. ## Инварианты хранилища медиа - **В `blobs/` попадает только дочитанное.** Загрузка идёт во временный файл в соседнем каталоге на том же томе и продвигается переименованием. Обрыв оставляет `.part`, который подметает следующий старт, а не обрезанную картинку, неотличимую от настоящей навсегда. - **Тип — по сигнатуре, никогда по URL, расширению или `Content-Type`.** Два из трёх выбирает тот, кто отдаёт файл, и расширение на диске у пользователя не должно зависеть от чужого сервера. - **`GIF89a` не доказывает анимацию**, и APNG не определяется по фиксированному префиксу: нужен обход блоков (второй Image Descriptor) и чанков (`acTL` раньше первого `IDAT`). Тихая ошибка, поэтому обходчики изолированы за `internal static` швами и проверяются на массивах байтов. - **`ref_count` денормализован и пересчитывается, а не инкрементится.** Апсерт `item` может заменить строку, указывавшую на другой blob, и слепой `+1` уехал бы навсегда. Есть `VerifyReferenceCountsAsync`, и он часть замысла, а не отладка. - **Журнал `seen_url` переживает чистку.** Иначе следующий прогон скачает заново ровно то, что пользователь только что удалил. Терминальные исходы отделены от повторяемых: отказ описывает момент, а не ресурс, и считать его окончательным значит терять контент на каждой сетевой икоте. - **Вердикт лизы — про транспорт, а не про ресурс.** 404 и 429 — это успех прокси. Иначе пул карантинил бы рабочие адреса ровно с той частотой, с какой встречаются мёртвые ссылки, а на лимит отвечал бы сменой прокси, то есть обходом лимита. - **Троттл поднимается только сигналами хоста** (429/503 с `Retry-After`) и никогда не приводит к ротации прокси. Флажка «повторить через другую прокси при 429» в настройках быть не должно. - **Жёсткая ссылка — привилегия ФС, а не гарантия.** Откат на копию удваивает расход диска, поэтому достигнутый режим пишется в `showcase_mode` и виден в UI. - **Имя из `SuggestedName` враждебно.** Остаётся только последний сегмент, разделители не переживают, устройства Windows отодвигаются, расширение берётся из типа. ## Добавить строку в UI 1. Ключ и оба перевода — в `Strings.resx` и `Strings.ru.resx` (**оба**, иначе упадёт `LocalizationTests.Russian_translates_every_english_key`). 2. В XAML — `{l:Loc Ключ}`, во ViewModel — `Localizer.Instance[...]` / `.Format(...)`. Никакого хардкода в `Views/` кроме имени продукта и примеров адресов. - **Счётчики — только через `Localizer.Plural`** с ключами `.One` / `.Few` / `.Many`. У русского три формы; «{0} records» с приклеенным окончанием непереводимо. - **Перечисления в списках — `LocalizedOption`**, не сырые значения. Конвертер разрешил бы подпись один раз и не заметил смены языка. Идентичность обёртки — значение перечисления, чтобы выбор не слетал. - **Текст из домена переводится по коду.** `Core` о языках не знает: `ParseError` несёт `Code` и `Arguments`, UI ищет `Collect.Error.{Code}` с откатом на `Message`. Имена источников — так же: `Source.{id}.Name` с откатом на `DisplayName`, поэтому обещание «добавить источник = одна строка» остаётся в силе. - **VM, у которой есть производный от языка текст, переопределяет `OnLanguageChanged`** и зовёт `base`. Без этого заголовок страницы останется на прежнем языке. ## Грабли, уже оплаченные - **Селектор типа в Avalonia матчит точный тип.** `UserControl.shell` не матчит `ShellView` (наследник `ReactiveUserControl`) и молча не делает ничего. Использовать `:is(UserControl).shell`. Голый `.shell` тоже матчит, но тогда XAML-компилятор не может вывести тип для `Setter` и падает с AVLN2200. - **Style-сеттер навсегда проигрывает локальному значению.** Не стилизовать `IsPaneOpen` и `DisplayMode` — они биндятся во ViewModel. - **`IPseudoClasses.Set` требует ведущего `:`**. - **`InvariantGlobalization` обязан быть `false`**: Semi.Avalonia строит `CultureInfo` в статическом конструкторе и падает целиком. - **Превьюер рефлексирует безпараметровый статический `BuildAvaloniaApp()`.** Необязательный параметр ломает его вызов, вторая перегрузка — `AmbiguousMatchException`. - **Compiled bindings включены по умолчанию** (Avalonia 12): `x:DataType` нужен на каждом `UserControl` и каждом `DataTemplate`. - **`Avalonia.Headless.XUnit` 12.x — это xUnit v3**, а не v2. Тестовые проекты — `Exe`. - **Headless: ручной `Measure`/`Arrange` внутри окна бесполезен** — следующий проход layout окна вернёт свой размер. Задавать ширину самому `Window`. Но и без окна нельзя: у открепленного контрола не строится визуальное дерево. - **csharpier — единственный владелец форматирования** (включая `.axaml` и `.csproj`). `IDE0055` понижен до suggestion: два форматтера с `TreatWarningsAsErrors` дерутся насмерть. - **`Condition="'$(Configuration)' == 'Debug'"` на `PackageReference` — ловушка.** `dotnet restore` вычисляется с конфигурацией по умолчанию, поэтому пакет обязан резолвиться, даже если эту конфигурацию никто не собирает. Так тут проехал мёртвый `Avalonia.Diagnostics` (его нет под Avalonia 12): `dotnet build -c Release` работал, а голый `dotnet restore` падал. - **`Execute()` завершился ≠ `IsExecuting` уже false.** Второе публикуется на выходном планировщике. Тест, который сразу после `await` дёргает команду, закрытую по чужому `IsExecuting`, будет мигать под нагрузкой — ждите `CanExecute`, а не предполагайте. - **Проект VM-тестов не параллелится.** `ReactiveUiBootstrap` ставит глобальные планировщики ReactiveUI, то есть тесты делят изменяемое состояние независимо от их желания. - **Инспектора в Avalonia 12 нет из коробки.** `Avalonia.Diagnostics` закончился на 11.3.x; DevTools живут отдельно (`AvaloniaUI.DiagnosticsSupport` + `.WithDeveloperTools()`), со своей установкой. Зависимость намеренно не добавлена. ## Качество - Центральные версии пакетов — `Directory.Packages.props`. **Никаких `Version=` в csproj** (иначе NU1008 на restore). - `TreatWarningsAsErrors` включён; NuGet-advisory (`NU19xx`) выведены из ошибок, чтобы свежая CVE не роняла сборку кода, который никто не трогал. - Тестовые послабления анализаторов — в `tests/Directory.Build.props`, не в самих тестах. ## Границы, выбранные намеренно - **robots.txt не читается.** Оба источника v1 либо принадлежат пользователю, либо введены им вручную, поэтому спрашивать разрешения не у кого. Это решение, а не забывчивость: **с появлением третьего источника, который ходит по чужому сайту, robots.txt становится обязательным.** - **Перебора идентификаторов нет и не планируется.** Источник перечисляет то, что сайт сам публикует; подбор адресов — это не «сбор опубликованного», и прокси-пул существует ради лимитов и доступности, а не ради их обхода. - **Возобновления по `Range` нет.** Оборванная загрузка выбрасывается целиком; частичный файл в хранилище дороже, чем повторное скачивание. - **SVG не поддерживается сознательно** — это текст, он умеет исполнять скрипты и несёт XXE. BMP/ICO/HEIC/JPEG-XL просто отложены. - **Перцептивных хешей нет.** Дедуп точный, по SHA-256; «похожие» картинки — отдельная задача.