The scaffolding domain existed to prove the shell end to end before there was
anything real to put in it. There is now, so it goes - as CLAUDE.md promised it
would.
Gone: the two sample parsers, ITextParser, ParsedRecord, the parser catalog,
ParseViewModel and ParseView, their tests, and the settings key that remembered
which parser was last used. ParseError.LineNumber becomes Index, since for a
listing "line 42" was simply untrue, and the error keys move from Parse.Error.*
to Collect.Error.* now that parsing is not a concept here.
Kept: IParser<,>, ParseOutcome, ParseProgress and ParseError. The streaming
contract was always the general part - it was only ever the text-shaped closure
of it that was scaffolding.
Rendering the dashboard caught two keys that were referenced but never added
during the rename: the XAML was repointed and the resources were not. The parity
test could not see it, because it compares the two files against each other and
a key absent from both is consistent. That gap now has its own test, which reads
every {l:Loc} in the XAML and checks it resolves - a screenshot is too late and
too manual a way to find a missing string.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
257 lines
22 KiB
Markdown
257 lines
22 KiB
Markdown
# 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<T>` / `BehaviorSubject<T>` | `Signal<T>` / `BehaviorSignal<T>` |
|
||
| `RxApp.MainThreadScheduler` | `RxSchedulers.MainThreadScheduler` |
|
||
| `TestScheduler` | `VirtualClock`, `ImmediateSequencer.Instance` |
|
||
|
||
Привычные имена операторов (`Select`, `Where`, `Throttle`, `DistinctUntilChanged`,
|
||
`CombineLatest`) **работают** — Primitives отдаёт оба набора. `using ReactiveUI.Primitives;`
|
||
нужен ради `Subscribe(Action<T>)`.
|
||
|
||
**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<T>`**, не сырые значения. Конвертер разрешил бы
|
||
подпись один раз и не заметил смены языка. Идентичность обёртки — значение перечисления, чтобы
|
||
выбор не слетал.
|
||
- **Текст из домена переводится по коду.** `Core` о языках не знает: `ParseError` несёт `Code` и
|
||
`Arguments`, UI ищет `Collect.Error.{Code}` с откатом на `Message`. Имена источников — так же:
|
||
`Source.{id}.Name` с откатом на `DisplayName`, поэтому обещание «добавить источник = одна строка»
|
||
остаётся в силе.
|
||
- **VM, у которой есть производный от языка текст, переопределяет `OnLanguageChanged`** и зовёт
|
||
`base`. Без этого заголовок страницы останется на прежнем языке.
|
||
|
||
## Грабли, уже оплаченные
|
||
|
||
- **Селектор типа в Avalonia матчит точный тип.** `UserControl.shell` не матчит `ShellView`
|
||
(наследник `ReactiveUserControl<T>`) и молча не делает ничего. Использовать
|
||
`: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; «похожие» картинки — отдельная задача.
|