Files
jellybit/docs/review.md
T
av 52615e4e49 review: триггеры профиля отвязаны от затронутого узла
- перечни узлов названы картой мест, где живут правила; ступень поднимает
  новое или изменённое правило, а не правка рядом с ним
- заведён закрытый перечень отсекающих условий: исход, названный дельта-спекой
  поимённо, отсутствие новых сценариев, правка сообщений и тестов, сужение
  существующей нормализации
- записан ориентир частоты: deep — исключение на крупной функциональности,
  задача-уборка идёт в quick или standard даже в узле из перечня
2026-08-06 17:55:29 +03:00

279 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Ревью: настройка и журнал
Проектная часть конвейера ревью: чем jellybit отличается от абстрактного
Go-сервиса и что здесь уже проскакивало. Устройство самого конвейера (профили,
стадии, контракт находок) живёт в скилле, а не здесь.
## Как настроен конвейер
### Типовые узлы
Рода узлов проекта и проверяемые свойства к каждому. Род, а не инвентарь
пакетов: узел, которого ещё нет, но который проект заведёт, включён намеренно.
**Клиент внешнего HTTP-сервиса** (`qbt`, `llm`, `metadata`, `jellyfin`, `tgbot`)
- у каждого исходящего вызова свой таймаут из конфига, не дефолт транспорта;
- `context` доходит до запроса и отменяет его, а не игнорируется;
- ошибка зависимости отличима от ошибки нашей логики на приёме результата;
- секреты (пароль, ключ, токен) не попадают ни в лог, ни в текст ошибки;
- недоступность **опциональной** зависимости (метабаза, Jellyfin, Telegram) не
двигает состояние загрузки и не краснеет ERROR-ом в фоновом цикле.
**Тик воркера и переход состояния**
- переход легален по декларативному графу, а не «просто присвоили `state`»;
- работа идёт под per-download блокировкой; два транспорта не гонятся;
- тик идемпотентен: повтор на том же состоянии не порождает второго эффекта;
- отмена контекста на середине не оставляет полуприменённого состояния;
- новое промежуточное состояние имеет выход **и** предохранитель по времени.
**Репозиторий `store`**
- запрос параметризован, время только через `store.Now()`, id через `ident`;
- многошаговое изменение — в одной write-транзакции (`_txlock=immediate`);
- инвариант, не выражаемый схемой («одна активная загрузка на infohash»),
держится guarded-методом, а не проверкой в вызывающем коде;
- миграция forward-only и сопровождается правкой [database.md](database.md).
**Операция с файловой системой** (`layout`)
- целевой путь проверяется **после** `filepath.Clean`, на принадлежность
библиотеке;
- существующая цель не перезаписывается ни при каком исходе;
- под `paths.downloads` нет ни одной операции записи или удаления;
- частичный сбой батча оставляет систему в состоянии, из которого повтор
доводит начатое или откатывает целиком;
- удаление снимает только свои ссылки своего батча и не снимает последнюю копию.
**Парсер недоверенного входа** (`magnet`, `torrent`, парсер сообщения бота,
разбор ответа LLM)
- вход враждебный по умолчанию: длина, вложенность, мусорные байты, пустота;
- разбор не паникует и не аллоцирует по числу из самого входа;
- невалидный вход даёт доменную ошибку, а не тихий дефолт;
- результат нормализуется на границе (lowercase hex, trim, `ident.Parse`).
**htmx-хендлер**
- один партиал обслуживает страницу и фрагмент, ветвление по `isHTMX`;
- ошибка на htmx-пути — 200 плюс фрагмент, а не 4xx/5xx;
- страница деградирует без JS;
- поллинг самозавершается, когда наблюдать больше нечего.
### Типовые ложноположительные
Находки, которые здесь выглядят убедительно и всегда неверны.
- **«Веб-UI и REST без авторизации».** Принятое решение под сегодняшний
периметр — [security.md](security.md). Дефектом станет только вместе с путём
снаружи LAN.
- **«Ошибка на htmx-пути возвращает 200».** Так и задумано —
[conventions/web-ui.md](conventions/web-ui.md).
- **«Решение auto/review должно опираться на `confidence` модели».** Неверно в
форме «вместо матча»: авто только при подтверждённом матче в базе —
[ADR-2026-06-13-auto-link-requires-db-match](adr/ADR-2026-06-13-auto-link-requires-db-match.md).
Самооценка LLM плохо откалибрована и поддаётся инъекции. Порог `confidence`
при этом существует **дополнительным** блокирующим условием поверх матча —
его наличие дефектом не является.
- **«Копировать надёжнее, чем хардлинк» / «взять симлинк».** Хардлинк —
осознанный выбор ради неприкосновенности источника и недублирования диска,
[ADR-2026-06-13-hardlinks](adr/ADR-2026-06-13-hardlinks.md); copy — только
фолбэк.
- **«Целочисленный автоинкрементный ключ был бы проще».** ULID — требование
capability `identity`; `AUTOINCREMENT` вдобавок краснит гейт.
- **«Не хватает метрик, трейсинга, health-эндпоинтов по каждой зависимости».**
«Минимум компонентов» — принцип проекта; глубокий healthcheck заведён задачей
и ждёт своей очереди, а не является упущением.
- **«Здесь нужен интерфейс, чтобы это можно было замокать».** Единственная
реализация за интерфейсом — обычно лишний слой; см. «Честный предел» ниже.
- **«Нет ретрая у вызова в фоновом цикле».** Тик повторится сам через
`poll_interval`; ретрай внутри тика чаще вреден.
- **«Оригинальное и локализованное названия дублируются — избыточность».**
`original_title` заполняется всегда и при неуверенности дублирует `title`
это контракт capability `recognition`, а не недосмотр.
### Вопросы к проходам
Форма: `<имя прохода>: <вопрос> (<провенанс>)`. Журнал дефектов пока пуст,
поэтому провенанс у всех пунктов — инвариант или ADR, а не пойманный случай;
по мере накопления журнала список должен смещаться в сторону реальных промахов.
- `adversary`: можно ли, управляя только именами файлов в раздаче и текстом
контекста, добиться целевого пути вне `paths.movies`/`series` — включая путь
через юникод, длину сверх лимита ФС и коллизию после нормализации?
(инвариант «целевой путь строго под библиотекой», [security.md](security.md))
- `adversary`: есть ли последовательность команд, после которой снимается
**последняя** копия данных — с учётом `superseded`-ссылок и гонки со сверкой?
(инвариант «источник неприкосновенен»)
- `adversary`: что даёт крафт-магнет с чужим или подставным инфохэшем —
присоединение к чужой активной загрузке, отравление владения?
(открытая задача про идентичность инфохэшей)
- `adversary`: где признак «это наше» снимается с одной сущности, а действие
применяется к другой — присутствие раздачи в qBittorrent против байтов на
диске, запись в БД против файла, инфохэш против содержимого? (журнал,
2026-08-06: уборка своего торрента сносила чужие файлы)
- `ops`: что делает эта ветка, когда qBittorrent недоступен несколько минут
подряд — сколько ERROR-строк в секунду и меняется ли состояние задач?
(задача про ERROR-шторм фоновых циклов)
- `ops`: как это ведёт себя при сотне загрузок в базе и десятках тысяч
`file_link` — есть ли запрос без индекса и полный проход по таблице?
(задача про масштаб 100/1000, [database.md](database.md) → «Настройки»)
- `ops`: что остаётся на диске и в базе, если процесс убит посреди раскладки
батча? (состояние `linking` и его восстановление)
- `code`: логирующий чекпоинт один на операцию — или ошибка залогирована и
возвращена вверх, где залогирована снова?
([conventions/logging.md](conventions/logging.md))
- `code`: новое поле конфига появилось в `config.example.toml` с описанием
назначения, диапазона и единиц? ([conventions/config.md](conventions/config.md))
- `specs`: не завелось ли поведение, которого спека не заказывала — тихий
дефолт, проглоченная ошибка, ретрай «на всякий случай», отброшенное поле?
- `architecture`: не появился ли второй способ делать то, что уже делается —
второе место, где генерится время или id, второй парсер источника, вторая
логика целевых имён мимо `naming`?
([architecture.md](architecture.md) → «Единые точки проекта»)
### Триггеры профиля
Уточняет умолчания конвейера, не отменяет их. Рабочее умолчание — `standard`;
миграция схемы и публичный контракт ступень **не** поднимают: их проверяют
проходы, которые в `standard` и так есть.
**Новым понятием или структурной единицей здесь считается** (→ `wide`): новый
пакет `internal/*`; новая capability в `openspec/specs/`; новый транспорт приёма
или уведомлений рядом с REST, веб-UI, ботом и CLI; новый провайдер метабазы за
существующим интерфейсом; новое состояние в графе переходов загрузки; перенос
ответственности между `worker`, `recognition`, `layout` и `store`.
**Правила идентичности, слияния и разбора живут здесь** (→ `deep`): владение
раздачей по инфохэшу и сверка с qBittorrent (`ident`, `internal/store`,
`state-reconciliation`); построение целевых путей и санитизация имён
(`internal/layout`, `naming`); разбор недоверенного входа — bencode, magnet,
текст контекста, ответ LLM (`internal/torrent`, `internal/magnet`,
`internal/tgbot/parse.go`, разбор ответа модели); выбор кандидата метабазы и
слияние его полей с догадкой LLM (`internal/metadata`, `metadata-match`);
merge-раскладка при повторном добавлении раздачи.
- «Поведение, видимое снаружи» здесь включает **тексты и карточки Telegram**
для единственного пользователя это и есть интерфейс.
**Место из списка ступень не поднимает — поднимает правило.** Перечни выше
отвечают «здесь такие правила водятся», а не «любая правка здесь идёт в `deep`».
Ступень поднимает то, что даёт работу новому проходу: заводится ключ сравнения
или меняется его состав; у разбора появляется новый вид входа или новая ветка
неоднозначности; в слияние добавляется источник или меняется победитель при
конфликте.
**Отсекающие условия — проверяются до выбора профиля, любое сработавшее держит
ступень внизу.** Перечень закрытый, каждый пункт проверяется взглядом на дифф и
дельта-спеку:
- **дельта-спека называет исход поимённо.** Независимая реализация окупается
выбором, которого спека не сделала. Если сценарий уже говорит, что даёт
вырожденный вход, реализация повторит спеку, и дифф покажет расхождение в
форме, а не в решении;
- **новых сценариев в дельта-спеке нет** — изменение уточняет уже описанное
поведение, а не заказывает новое;
- **правка сообщения, комментария, записи журнала, имени или теста** в узле из
перечня;
- **сужение уже существующей нормализации** без нового вида входа: вход остался
тот же, изменился исход на одном его значении.
**Ориентир частоты.** `standard` закрывает большинство задач, `wide` — редкий
случай, `deep` — исключение на крупной функциональности, а не на уборке. Задача
типа `chore` или `bugfix`, собранная из нитей прошлого ревью, идёт в `quick` или
`standard`, даже когда трогает файл из перечня `deep`. Верхняя ступень чаще одной
задачи на спринт означает ошибку в критерии, а не спринт из сложных задач.
### Недоступно проверке
**Не проверит ни один проход** — принципиальная граница, по факту промаха не
пересматривается.
- История инцидентов на umbar и то, что уже ломалось в проде.
- Поведение таблицы SQLite под реальным объёмом и профилем нагрузки: реального
профиля нет ни у кого, кроме сервера.
- Завязка внешних потребителей (Jellyfin, закладки, чужие ссылки) на текущее
поведение.
- Качество распознавания как таковое: правильно ли LLM определил фильм — вопрос
тюнинга модели и промпта, а не ревью кода. Размеченный корпус, по которому это
можно было бы судить числом, решено не собирать (`tasks/REJECTED.md`,
2026-08-06).
- Суждение «этой функциональности не должно существовать».
**Перестали проверять сознательно** — пересматривается первым, как только
что-то проскочило.
- **Идиоматичность Go — с 2026-08-04.** Проектный проход `idiom` (поимённая
сверка с положениями Effective Go, Go Code Review Comments, стайлгайдов Uber
и Google) удалён вместе с проектными копиями агентов при переезде на плагин
`av-dev-pipeline`, который этот проход упразднил. Способные части переселены:
эксперимент против поведения библиотеки и драйвера — в `ops`, «не изобретаем
ли то, что уже есть в библиотеке» — в `architecture`. **Различение
«идиоматично против распространено» теперь не спрашивает никто.** Класс
обратимый: портит форму кода, не данные. Пересмотр — задача
`quality-review-agents`.
## Журнал дефектов
Запись на каждый воспроизведённый дефект, **сразу**, а не ретроспективно: со
временем теряется не факт, а причина непоймания. Проскочившие — эвал-сет для
калибровки конвейера, выборка по пометке.
Форма:
<!-- копия: журнал-дефектов-форма из av-dev-pipeline/skills/review-pipeline/references/review-journal.md -->
## ГГГГ-ММ-ДД — <краткое последствие> [проскочил|пойман]
- **Где:** путь:строка либо «конвейер, а не код»
- **Симптом:** как обнаружилось, кем и когда
- **Причина:** что на самом деле было не так
- **Чем воспроизведён:** тест, команда, замер — с числами
- **Почему не поймали:** только для проскочивших — какой проход обязан был найти
и что ему помешало
- **Что меняем:** правило прохода, шаг гейта, конвенция, факт в документе
проекта — либо «ничего, цена поимки выше цены дефекта»
<!-- /копия: журнал-дефектов-форма -->
### Записи
Журнал заведён 2026-07-23 вместе с переработкой конвейера
([ADR-2026-07-23-review-pipeline-generative](adr/ADR-2026-07-23-review-pipeline-generative.md));
случаи до этой даты не восстанавливались — восстановленная постфактум причина
непоймания недостоверна, а именно она и нужна.
## 2026-08-06 — уборка своего торрента после отмены сносит чужие файлы [проскочил]
- **Где:** `internal/worker/worker.go:501-556` — гард `:501-509`, удаление
`:550`. Норма — `openspec/specs/download-tracking/spec.md`, требование
«Добавление пойманной загрузки в qBittorrent».
- **Симптом:** найден проходом `adversary` на ревью задачи
`dismiss-marker-lost` (2026-08-06), не в эксплуатации. В проде не всплывал.
- **Причина:** гард «подтверждённое отсутствие непосредственно перед `add`»
подтверждает отсутствие **записи торрента** в qBittorrent, но не отсутствие
**данных** на диске. Пользователь, снявший раздачу из qBittorrent с
сохранением файлов (`download-tracking` сама предписывает это как способ
восстановления зависшей magnet-раздачи), и подавший тот же торрент заново,
получает `add`, подхватывающий пред-существующие файлы. Отмена в окне между
re-read и `PromoteCatched` даёт `torrents/delete` с `deleteFiles=true` по
этим файлам. Исключение инварианта «источник неприкосновенен» покрывает
«собственный торрент», а признак «своё» подменён на «торрента не было».
- **Чем воспроизведён:** тестом на фейковом клиенте qBittorrent во временном
каталоге прогона (`tmp/`, не сохранён): `Cancel` в окне после `add` вызывает
`Delete(hashes, deleteFiles=true)` при живом файле под `paths.downloads`,
созданном до `add`. Тот же путь достижим через `Dismiss` — гейт `Dismiss`
шире, а уборка срабатывает по состоянию (`after.State != catched`), а не по
команде. **Не прогонялся** последний шаг — что боевой qBittorrent по
`deleteFiles=true` физически сносит пред-существующий контент: в бой ходить
запрещено, отсюда `Confidence: medium`.
- **Почему не поймали:** окно после `add` разбиралось как **гонка** (кто
успел — отмена или промоушен) и проверялось на «не удалим ли чужой торрент».
Вопрос «а если торрента нет, но данные есть» не задавал никто: ни один
проход не спрашивал про **асимметрию признака владения** — признак снимается
с одной сущности (запись в qBittorrent), а действие применяется к другой
(байты на диске). Враждебный проход до этой задачи на данном коде не гонялся.
- **Что меняем:** вопрос `adversary` в разделе выше дополнен пунктом про
асимметрию признака владения. Сам дефект — задачей в беклоге, кандидат
`critical`; спека `state-reconciliation` в том же изменении перестала
утверждать, что уборка «данных пользователя не касается».