Files
av-parser/CLAUDE.md
T
Leonid PershinandClaude Opus 5 f8744c930a Remove the demo text-parsing domain
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>
2026-08-13 22:23:52 +03:00

22 KiB
Raw Blame History

CLAUDE.md

Конвенции этого репозитория и грабли, на которые здесь уже наступили. Читать до правок.

Команды

Полный локальный гейт (формат → сборка → тесты) — то, что нужно прогнать перед коммитом:

./build.ps1

Отдельные шаги, если нужен только один из них:

dotnet build AvParser.slnx -c Release
dotnet test AvParser.slnx -c Release
dotnet csharpier check .
./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; «похожие» картинки — отдельная задача.