layout: непомещающееся целевое имя уводит задачу в review вместо failed
- предел длины компонента (255 байт) проверяется в BuildLinks до первой операции с ФС: ни каталога, ни ссылки при отказе не создаётся - причина пустого предпросмотра считается на показе (ReviewData.PreviewError) и печатается в панели действий и в карточке Telegram: у задачи без записанной причины взять её больше неоткуда
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-10
|
||||
@@ -0,0 +1,106 @@
|
||||
## Context
|
||||
|
||||
`layout.BuildLinks` — чистая функция от плана: строит целевые пути, проверяет
|
||||
выход за библиотеку и не трогает диск. Длину имени она не проверяет, поэтому
|
||||
слишком длинное название доезжает до `Apply`, где `os.MkdirAll` или `os.Link`
|
||||
получают от ядра `ENAMETOOLONG`. `worker.linkPlan` разбирает исход `Apply` двумя
|
||||
ветками: `layout.ErrCollision` → `review`, всё остальное → `failed` с текстом
|
||||
ошибки в `error_msg`. Системная ошибка попадает во вторую.
|
||||
|
||||
Путь «отказ построения → review» в проекте уже есть: ошибка `BuildLinks` уводит
|
||||
задачу в `review` с кодом `build` (`worker/review.go`). То есть чинить надо не
|
||||
маршрут, а место проверки и различимость причины.
|
||||
|
||||
Предел длины на ext4/xfs/btrfs — 255 **байт** на компонент (`NAME_MAX`), а не
|
||||
символов: кириллическое название в UTF-8 тратит по два байта на букву и
|
||||
упирается вдвое раньше, чем кажется по числу знаков.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Непомещающееся имя даёт `review` с доменной причиной, а не `failed` с текстом
|
||||
ядра.
|
||||
- Отказ целиком: ни каталога, ни ссылки. Проверка стоит до первой операции с ФС.
|
||||
- Причина различима кодом — по ней видно, чем этот вход в `review` отличается от
|
||||
коллизии и от прочих отказов построения.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- **Не обрезаем и не переименовываем название сами.** Это решение человека, и
|
||||
подсказка названия на ревью для него уже есть. Автоматическая обрезка дала бы
|
||||
папку, которую правило сходимости потом унаследует навсегда.
|
||||
- **Не выясняем предел у ядра** (`pathconf(_PC_NAME_MAX)`, пробная запись).
|
||||
Проверка обязана отвечать до обращения к диску, а на разных подкаталогах
|
||||
ответы могут различаться.
|
||||
- **Не проверяем предел длины пути целиком** (`PATH_MAX`, 4096 байт). Корни
|
||||
библиотек задаёт оператор, глубина у нас фиксированная (папка тайтла + папка
|
||||
сезона + файл), и трёх компонентов по 255 байт до 4096 не хватает.
|
||||
|
||||
## Decisions
|
||||
|
||||
**Решение 1. Проверка живёт в `BuildLinks`, по компонентам относительного
|
||||
пути.** `BuildLinks` уже единственное место, где целевой путь собирается целиком
|
||||
и проверяется на выход за библиотеку, — вторая проверка того же пути встаёт
|
||||
рядом с первой. Проверяются компоненты **под корнем библиотеки**, а не весь путь:
|
||||
корни задаёт оператор, и жаловаться на его каталоги раскладка не вправе.
|
||||
|
||||
**Решение 2. Предел — константа 255 байт с объяснением, откуда взята.**
|
||||
`NAME_MAX` у ext4, xfs и btrfs — 255 байт; это предел самой распространённой
|
||||
раскладки, а не универсальный закон. Значение стоит именованной константой в
|
||||
`internal/layout` с комментарием, называющим источник. В конфиг не выносится:
|
||||
настройка, которую никто не знает, как выставить, — это не гибкость.
|
||||
|
||||
**Решение 3. Своя ошибка `layout.ErrNameTooLong`, обёрнутая как остальные.**
|
||||
`errors.Is` по ней ловит случай на любой глубине обёртки — так же, как
|
||||
`ErrCollision`. Текст ошибки называет компонент и его длину в байтах: без числа
|
||||
человек не поймёт, насколько сокращать.
|
||||
|
||||
**Решение 4. Свой код причины `name_too_long` рядом с `collision`.** Ошибка
|
||||
`BuildLinks` сегодня даёт код `build` — он верен, но объединяет непомещающееся
|
||||
имя с пустым названием и серией без номера. Код причины в этом проекте —
|
||||
корреляционный ключ шага, и слепить три разных отказа в один значит потерять
|
||||
возможность отличить чинимое подсказкой от нечинимого. Ветка встаёт в `linkPlan`
|
||||
перед общей веткой `build`.
|
||||
|
||||
**Решение 5. Проверка длины идёт после проверки выхода за библиотеку.**
|
||||
Путь, вылезший из песочницы, — это `critical`; сообщить о нём длиной имени
|
||||
значило бы скрыть находку безопасности за косметической причиной. Порядок
|
||||
проверок в цикле сохраняет приоритет.
|
||||
|
||||
**Решение 6. Превью получает проверку даром — и это довод за `BuildLinks`.**
|
||||
Оба предпросмотра ревью (карточка и строка источника) строят пути тем же
|
||||
`BuildLinks` (`worker/review.go`), поэтому вердикт на показе и вердикт на
|
||||
применении совпадают по устройству, а не по договорённости. Цена — видимая:
|
||||
непомещающееся имя даёт пустой предпросмотр, а панель действий при пустом
|
||||
предпросмотре прячет «Применить» и печатает общий текст «Подтверди источник».
|
||||
Баннер причины над карточкой при этом верен. Поведение не новое — так уже ведут
|
||||
себя все отказы построения плана, — но это изменение делает его достижимым на
|
||||
штатном входе.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Предел зашит и может не совпасть с реальной ФС.** На ФС с меньшим
|
||||
`NAME_MAX` (например, некоторые зашифрованные) имя пройдёт проверку и упрётся в
|
||||
ядро — тот же `failed`, что сегодня, но уже только на редкой раскладке. На ФС с
|
||||
бо́льшим пределом мы отказываем строже, чем нужно. Обе стороны названы в
|
||||
комментарии константы; лечение — правка константы, а не настройка.
|
||||
- **Причина различима, но не самообъясняема.** Человек видит «имя не
|
||||
помещается» и длину — но не видит, на сколько байт сокращать после
|
||||
provider-тега и суффикса серии. Считать это за него — отдельная работа, здесь
|
||||
она не делается.
|
||||
- **Изменение сдвигает часть нынешних `failed` в `review`.** Это и есть цель, но
|
||||
задачи, лежащие в `failed` с этой ошибкой прямо сейчас, сами туда не переедут и
|
||||
сверкой не подхватятся: из `failed` их выводит только `retry`, а он тянет
|
||||
полный цикл через `downloading` и распознавание. Обещания самоизлечения здесь
|
||||
нет.
|
||||
- **Унаследованная база отказа по длине не даёт, и это арифметика, а не
|
||||
везение.** На чекпоинте эта строка стояла как признанный тупик: база берётся с
|
||||
диска, подсказка её не укорачивает, задача циклится. Попытка написать тест
|
||||
показала, что случай недостижим. Папка-якорь лежит на диске, значит её имя уже
|
||||
не длиннее предела; хвост имени файла — `" S02E01"` плюс расширение, 11 байт —
|
||||
не длиннее хвоста имени папки, `" ["` плюс provider-тег плюс `"]"`, минимум 11
|
||||
байт (`tmdbid-1`). Язык и флаги субтитров и двойные серии в `layout.PlanFile`
|
||||
из распознавания не приходят вовсе (`toLayoutPlan` их не заполняет), поэтому
|
||||
длиннее не станет. Свойство держит тест `TestApply_InheritedBaseAtLimitStillFits`:
|
||||
если суффиксы вырастут, он покраснеет раньше, чем задача упрётся в тупик.
|
||||
@@ -0,0 +1,47 @@
|
||||
## Why
|
||||
|
||||
Название бывает длиннее, чем файловая система разрешает назвать файл. Сегодня
|
||||
раскладка узнаёт об этом от ядра, уже начав работу: задача падает в `failed`, а
|
||||
владелец видит вместо карточки ревью английскую строку `file name too long`.
|
||||
Состояние `failed` терминальное — починить случай нечем, хотя чинится он одной
|
||||
подсказкой на ревью, ровно как занятый целевой путь.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Раскладка проверяет длину имени **до** первой операции с файловой системой:
|
||||
каждый компонент целевого пути (папка тайтла, папка сезона, имя файла) обязан
|
||||
помещаться в предел, который держат распространённые файловые системы Linux.
|
||||
- Не помещается — раскладка отказывается целиком, не создав ни одного каталога и
|
||||
ни одной ссылки, и задача уходит в `review` со своей причиной («имя не
|
||||
помещается»), а не в `failed` с текстом системной ошибки.
|
||||
- Владелец правит название подсказкой на ревью и применяет заново — путь тот же,
|
||||
что при коллизии цели.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
Новых нет.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `file-layout`: новое требование о непомещающемся целевом имени — проверка до
|
||||
файловых операций, отказ целиком, уход в `review` с доменной причиной.
|
||||
- `review`: панель действий при пустом предпросмотре печатает записанную причину
|
||||
вместо общего «Подтверди источник». Пустой предпросмотр наступает от коллизии,
|
||||
от непомещающегося имени и от невалидного плана — текст один на всех и в трёх
|
||||
случаях из четырёх неверен.
|
||||
|
||||
## Impact
|
||||
|
||||
- `internal/layout` — проверка длины компонентов в `BuildLinks` (чистая функция,
|
||||
до `mkdir`/`link`) и своя доменная ошибка вместо системной.
|
||||
- `internal/worker` — ветка перевода задачи в `review` по этой ошибке, рядом с
|
||||
веткой коллизии цели; свой код причины для корреляции.
|
||||
- Веб-UI и Telegram — панель действий и карточка ревью называют причину, по
|
||||
которой плана нет; причина считается на показе, поэтому относится к текущему
|
||||
плану. Ревью показало, что взять её из записанного поля недостаточно: на самом
|
||||
частом входе оно пусто.
|
||||
- `internal/httpapi` — трансляция новой доменной ошибки в конфликт, а не в сбой
|
||||
сервера; `internal/tgbot` — своя ветка ответа, как у коллизии.
|
||||
- Внешних зависимостей, схемы БД и конфига изменение не трогает.
|
||||
@@ -0,0 +1,161 @@
|
||||
# Ревью изменения `long-title-to-review` — триаж
|
||||
|
||||
Отчёт агента `review-triage`. Записан оркестратором: харнесс блокирует запись
|
||||
файлов подагентами.
|
||||
|
||||
## Сводка
|
||||
|
||||
- **Размер:** 9 изменённых файлов (+180/−5) плюс новый
|
||||
`internal/layout/name_length_test.go`. Продуктового кода ~47 строк.
|
||||
- **Метка:** `medium`, назначена человеком; разметчик дал `small`. Режим — по графу.
|
||||
- **Сигнал о заниженной метке:** от `review-code` — «`medium` адекватна, `small`
|
||||
был бы занижен: дифф трогает четыре слоя». Триаж поддержал основанием, которого
|
||||
сам сигнал не назвал: код причины `name_too_long` оседает в
|
||||
`downloads.error_code` и обратной правкой после мерджа не откатывается.
|
||||
- **Гейт:** зелёный целиком, подтверждён `autotests` независимо; ни одного `SKIP`,
|
||||
`-race` реально прогонялся.
|
||||
- **Находок на вход:** 14 плюс 1 promote-кандидат. **Осталось:** 5 (2 блокируют,
|
||||
3 к исправлению), 3 понижены в гипотезы, 2 promote.
|
||||
|
||||
### План разметки с исходом
|
||||
|
||||
| тема | дом | глубина | кто закрывает | исход |
|
||||
|---|---|---|---|---|
|
||||
| requirements | `openspec/specs/` + дельты | разбор | specs | закрыта, 3 находки |
|
||||
| autotests | `CLAUDE.md` → «Гейт» | — | autotests | закрыта, находок нет |
|
||||
| conventions | `docs/conventions/` | разбор | code | закрыта, 5 находок (склеены в 3) |
|
||||
| architecture | `docs/architecture.md` | разбор | basics | закрыта, 1 находка |
|
||||
| security | `docs/security.md` | разбор | basics | закрыта, находок нет |
|
||||
| operations | `docs/architecture.md` → «Эксплуатация» | разбор | basics | закрыта, 3 находки |
|
||||
| темы проекта | — | — | — | своих тем нет |
|
||||
|
||||
Тем без отчёта нет.
|
||||
|
||||
## Блокирует мердж
|
||||
|
||||
### 1. На самом вероятном входе экран ревью не называет причину и теряет «Применить»
|
||||
|
||||
- Файл: `internal/worker/review.go:1085-1095`, `:240`,
|
||||
`web/templates/partials/review_main.html:79`
|
||||
- Severity: major. Confidence: high.
|
||||
- Оракул: два дерева (`git archive HEAD` против того же среза с наложенным
|
||||
`git diff HEAD`), один вход — задача в `review` без записанной причины,
|
||||
название `strings.Repeat("ы", 200)`:
|
||||
|
||||
```
|
||||
HEAD: preview_links=2 (HasLinks=true) → «Применить» показана
|
||||
Apply → failed, msg=970 байт "…file name too long"
|
||||
рабочее дерево: preview_links=0 (HasLinks=false) → формы /apply нет
|
||||
StateError="" → «Подтверди источник…»
|
||||
```
|
||||
|
||||
- Последствие: путь «распознавание без подтверждённого матча → `review`» —
|
||||
штатный и самый частый вход в ревью. На нём экран советует подтвердить
|
||||
источник, который ни при чём. Изменение, чья цель «человек узнаёт причину», на
|
||||
этом входе делает диагностируемость хуже, чем была.
|
||||
- **Действие: развилка.** Сюда же сняты два спутника той же причины: текст
|
||||
печатается дважды (баннер + панель) и после смены источника в панели остаётся
|
||||
причина от плана, которого больше нет.
|
||||
- Найдено проходами: `specs`, `basics`; склеено триажем.
|
||||
|
||||
**Отработано:** причина пустого предпросмотра считается на показе и приезжает
|
||||
отдельным полем (`ReviewData.PreviewError`), панель предпочитает её записанной.
|
||||
Человек выбрал вариант (а) на повторном чекпоинте.
|
||||
|
||||
### 2. Карточка Telegram в `review` не называет причину вовсе
|
||||
|
||||
- Файл: `internal/tgbot/render.go:70-95`, `internal/tgbot/bot.go:396-415`
|
||||
- Severity: major. Confidence: high.
|
||||
- Оракул: рендер карточки на рабочем дереве, задача в `review` с записанной
|
||||
причиной `name_too_long` и пустым предпросмотром — упоминает причину `false`,
|
||||
кнопки «Применить» нет. Контроль: та же причина в состоянии `failed` (исход до
|
||||
изменения) печатала текст и давала кнопку «Повтор».
|
||||
- Последствие: владелец видит «Нужно подтверждение» без плана, без кнопки и без
|
||||
слова о длине имени. `proposal.md` обещал обратное; `docs/architecture.md` →
|
||||
«Единые точки» называет обе границы трансляции, обновлена была одна.
|
||||
- **Действие: развилка.**
|
||||
|
||||
**Отработано:** карточка печатает причину для любого случая (вариант (а)), плюс
|
||||
своя ветка ответа в колбэке, как у коллизии.
|
||||
|
||||
## Стоит исправить сейчас
|
||||
|
||||
### 3. Штатный отказ краснит логи как сбой, и три предписанных реестра о новой ветке не знают
|
||||
|
||||
- Файл: `internal/worker/worker.go:962-968`, `docs/conventions/errors.md:81-97`,
|
||||
`docs/database.md:210-214`
|
||||
- Severity: minor. Confidence: high.
|
||||
- Оракул: прогон ручного `Apply` с логгером Debug — `level=ERROR msg="command
|
||||
failed"`. `docs/conventions/logging.md:154` относит штатный конфликт к `DEBUG`
|
||||
и ставит `layout.ErrCollision` поимённо.
|
||||
- **Действие: инлайн.** **Отработано** целиком: `logCmd`, таблица `errors.md`,
|
||||
блок констант `database.md`, клауза 409 в дельте `review`.
|
||||
|
||||
### 4. Текст причины несёт непомещающееся имя целиком — 494 байта
|
||||
|
||||
- Файл: `internal/layout/name.go:29-31`
|
||||
- Severity: minor. Confidence: high.
|
||||
- Оракул: `Apply` с названием `strings.Repeat("ы", 200)` → `msg_len_bytes=494`.
|
||||
- **Действие: инлайн.** **Отработано:** имя усекается серединой (`shorten`, 40
|
||||
рун), точная длина остаётся числом.
|
||||
|
||||
### 5. Два сценария дельты не проверены ничем; комментарий у теста границы описывает не тот вход
|
||||
|
||||
- Файл: дельта `file-layout`, `internal/layout/name_length_test.go:109-113`
|
||||
- Severity: minor. Confidence: high.
|
||||
- **Действие: инлайн.** **Отработано, и по одному из сценариев — не так, как
|
||||
предлагал триаж.** Попытка написать тест на «унаследованную базу» показала, что
|
||||
случай **недостижим**: папка-якорь лежит на диске и потому ≤ предела, а хвост
|
||||
имени файла (11 байт) не длиннее хвоста имени папки (≥ 11 байт). Сценарий
|
||||
переписан на верное утверждение, свойство закреплено тестом
|
||||
`TestApply_InheritedBaseAtLimitStillFits`. Комментарий у границы исправлен,
|
||||
кириллическая граница добита.
|
||||
|
||||
## Гипотезы без доказательства
|
||||
|
||||
- Тупик с унаследованной базой не назван в тексте причины (`basics`, B-4).
|
||||
**Снято разбором:** случай недостижим, см. находку 5.
|
||||
- Рубрика R6 в `tasks.md` противоречит сценарию S6 дельты. **Снято:** после
|
||||
переписывания сценария противоречия нет.
|
||||
- Панель печатает любой `error_msg` без разбора кода, включая сырые тексты ошибок
|
||||
хранилища. Не отработано: тот же текст уже печатал баннер до изменения, нового
|
||||
канала не появилось.
|
||||
|
||||
## Promote candidates
|
||||
|
||||
- **Язык текстов sentinel-ошибок:** русский у тех, что уходят на операторскую
|
||||
поверхность, английский у прочих; правила в `docs/conventions/errors.md` нет.
|
||||
- **Внешний текст в персистентной диагностике — только усечённым.** В проекте
|
||||
есть и `shorten`, и `naming.truncate`, но правила «то, что уезжает в
|
||||
`error_msg`, усекается на границе» нет; отсюда находка 4.
|
||||
|
||||
## Границы покрытия
|
||||
|
||||
**Что запускалось.** Метка `medium`, режим по графу: `autotests`, `specs`,
|
||||
`code`, `basics`, `triage`. Ревью дизайна (`specs` + `rubric`) отработало
|
||||
отдельным чекпоинтом до кода.
|
||||
|
||||
**Что не запускалось и почему.** Всё, что требует запуска — построенные пути
|
||||
атаки, замеры под нагрузкой, эксплуатационный постмортем, — даёт только метка
|
||||
`large`. Живые qBittorrent, LLM, метабазы и Telegram не трогались; рабочая БД не
|
||||
трогалась.
|
||||
|
||||
**Чего не мог каждый проход.** `autotests` — отличить исполнение строки от её
|
||||
проверки. `specs` — судить о качестве распознавания. `basics` на `medium` —
|
||||
строить пути и снимать замеры. `code` — сверять с руководствами по стилю языка.
|
||||
Триаж — находить новое: он работает с чужими выводами.
|
||||
|
||||
**Потолки проходов.** Ни один проход не сообщил ни своего потолка, ни числа
|
||||
находок за срезом. Это находка о прогоне: по молчанию нельзя отличить «показал
|
||||
всё» от «показал верхушку».
|
||||
|
||||
**Не проверялось никем.** Решения проекта (`docs/adr/`) и записанные наблюдения
|
||||
(`docs/research/`) — процессные документы, прогон их не открывает; расхождение с
|
||||
записанным решением ловит `av-dev-docs:healthcheck`. Поимённая сверка с
|
||||
руководствами по стилю Go никем не задавалась. Альтернативной реализации, с
|
||||
которой можно сдиффить решения, у конвейера нет.
|
||||
|
||||
**Осталось на человеке.** История инцидентов на umbar; поведение SQLite под
|
||||
реальным объёмом; завязка внешних потребителей на текущее поведение; суждение
|
||||
«этой функциональности не должно существовать»; качество распознавания;
|
||||
идиоматичность Go (проход упразднён 2026-08-04).
|
||||
@@ -0,0 +1,70 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Непомещающееся целевое имя уходит в review
|
||||
|
||||
Система SHALL отклонять раскладку целиком, если хотя бы один компонент целевого
|
||||
пути (папка тайтла, папка сезона, имя файла с расширением и суффиксами) длиннее
|
||||
предела, и SHALL переводить задачу в `review` с доменной причиной, а не в
|
||||
`failed`. Проверка SHALL выполняться до первой операции с файловой системой: ни
|
||||
каталога, ни ссылки при отказе не создаётся. Причина SHALL нести собственный код,
|
||||
отличный и от кода коллизии, и от общего кода отказа построения плана, а
|
||||
человекочитаемый текст SHALL называть непомещающийся компонент и НЕ SHALL
|
||||
содержать текста системной ошибки.
|
||||
|
||||
Предел SHALL меряться в **байтах** UTF-8-представления имени, а не в символах:
|
||||
кириллическое название упирается вдвое раньше латинского той же длины в знаках.
|
||||
Величина — **255 байт** (`NAME_MAX` у ext4/xfs/btrfs); она фиксирована и у ядра не
|
||||
выясняется, потому что раскладка обязана отказать до обращения к диску. На
|
||||
файловой системе с меньшим пределом остаётся сегодняшний исход — отказ ядра и
|
||||
`failed`; это осознанный остаток, а не пробел.
|
||||
|
||||
Проверка длины SHALL выполняться **после** проверки нахождения пути под корнем
|
||||
библиотеки: путь, вышедший за песочницу, SHALL отклоняться как выход за
|
||||
библиотеку, иначе находка безопасности спряталась бы за косметической причиной.
|
||||
|
||||
Самостоятельно обрезать или переименовывать название система НЕ SHALL — это
|
||||
решение человека.
|
||||
|
||||
#### Scenario: Слишком длинное имя файла уходит в review
|
||||
|
||||
- **GIVEN** распознавание даёт название, из которого имя целевого файла
|
||||
складывается длиннее 255 байт
|
||||
- **WHEN** выполняется раскладка
|
||||
- **THEN** задача переходит в `review` с причиной «имя не помещается», ни один
|
||||
каталог и ни одна ссылка не созданы, а текст системной ошибки человеку не
|
||||
показан
|
||||
|
||||
#### Scenario: Предел меряется в байтах
|
||||
|
||||
- **GIVEN** два названия одинаковой длины в знаках — латинское и кириллическое
|
||||
- **WHEN** строятся целевые пути
|
||||
- **THEN** кириллическое отклоняется вдвое раньше латинского, а граница
|
||||
проходит между 255 и 256 байтами
|
||||
|
||||
#### Scenario: Слишком длинное имя папки тайтла уходит в review
|
||||
|
||||
- **GIVEN** название укладывается в имя файла, но папка тайтла с годом и
|
||||
provider-тегом длиннее предела
|
||||
- **WHEN** выполняется раскладка
|
||||
- **THEN** задача переходит в `review` с той же причиной, каталог тайтла не
|
||||
создан
|
||||
|
||||
#### Scenario: Выход за библиотеку важнее длины
|
||||
|
||||
- **GIVEN** целевой путь одновременно выходит за корень библиотеки и длиннее
|
||||
предела
|
||||
- **WHEN** строятся целевые пути
|
||||
- **THEN** отказ называет выход за библиотеку, а не длину имени
|
||||
|
||||
#### Scenario: Подсказка человека чинит случай
|
||||
|
||||
- **GIVEN** задача в `review` с причиной «имя не помещается»
|
||||
- **WHEN** человек задаёт название короче и применяет раскладку заново
|
||||
- **THEN** раскладка проходит штатно и задача переходит в `done`
|
||||
|
||||
#### Scenario: Унаследованная база в предел помещается всегда
|
||||
|
||||
- **GIVEN** база имени унаследована от живой папки-якоря по правилу сходимости
|
||||
- **WHEN** строятся имена файлов внутри этой папки
|
||||
- **THEN** они помещаются в предел, потому что папка-якорь лежит на диске и уже
|
||||
не длиннее предела, а хвост имени файла не длиннее хвоста имени папки
|
||||
@@ -0,0 +1,63 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Панель действий при пустом предпросмотре называет причину
|
||||
|
||||
Панель действий SHALL называть причину, когда предпросмотр раскладки пуст, а не
|
||||
печатать общее «Подтверди источник, чтобы получить превью раскладки». Первой
|
||||
SHALL идти причина, посчитанная **на показе** — отказ построения этого
|
||||
предпросмотра: она относится к текущему эффективному плану, тогда как записанная
|
||||
при последнем переходе после смены источника устаревает, а у задачи, пришедшей в
|
||||
`review` без записанной причины, её нет вовсе. Записанная причина SHALL
|
||||
использоваться, когда посчитанной нет. Общий текст SHALL оставаться только там,
|
||||
где нет ни той, ни другой — источник действительно ещё не подтверждён.
|
||||
|
||||
Построение предпросмотра НЕ SHALL двигать состояние задачи: причина считается на
|
||||
чтении и наружу отдаётся значением, а не записью.
|
||||
|
||||
Те же две причины в том же порядке SHALL показываться и в карточке Telegram,
|
||||
когда плана в ней нет: обе поверхности ревью объясняют отсутствие команды
|
||||
«Применить» одинаково. Команда, упершаяся в непомещающееся имя, SHALL отвечать
|
||||
конфликтом, а не сбоем сервера.
|
||||
|
||||
Требование не трогает доступность команды «Применить»: она по-прежнему следует
|
||||
наличию предпросмотра. Речь о том, что человеку говорят, когда предпросмотра нет:
|
||||
пустой предпросмотр наступает и от коллизии путей, и от непомещающегося имени, и
|
||||
от невалидного плана, а текст сегодня во всех случаях один и в трёх из четырёх
|
||||
неверен.
|
||||
|
||||
#### Scenario: Непомещающееся имя названо в панели действий
|
||||
|
||||
- **GIVEN** загрузка в `review` с причиной «имя не помещается», источник
|
||||
подтверждён, предпросмотр пуст
|
||||
- **WHEN** человек открывает экран ревью
|
||||
- **THEN** панель действий печатает причину отказа, а не предложение подтвердить
|
||||
источник, и команда «Применить» недоступна
|
||||
|
||||
#### Scenario: Причина не записана в состоянии — считается на показе
|
||||
|
||||
- **GIVEN** загрузка пришла в `review` без записанной причины (нет матча), а её
|
||||
название не помещается в имя файла
|
||||
- **WHEN** человек открывает экран ревью
|
||||
- **THEN** панель действий называет длину имени, хотя в состоянии причины нет, и
|
||||
состояние при этом не меняется
|
||||
|
||||
#### Scenario: После смены источника показывается свежая причина
|
||||
|
||||
- **GIVEN** загрузка в `review` с записанной причиной «имя не помещается», и
|
||||
человек выбрал другой источник
|
||||
- **WHEN** экран перестраивается
|
||||
- **THEN** показывается причина, посчитанная для нового плана, а не записанная
|
||||
при прошлом переходе
|
||||
|
||||
#### Scenario: Карточка Telegram называет ту же причину
|
||||
|
||||
- **GIVEN** загрузка в `review`, плана в карточке нет
|
||||
- **WHEN** карточка отправляется или обновляется
|
||||
- **THEN** в ней есть строка с причиной, по которой план не построился
|
||||
|
||||
#### Scenario: Источник не подтверждён — текст прежний
|
||||
|
||||
- **GIVEN** загрузка в `review` без записанной причины, без посчитанной и без
|
||||
предпросмотра
|
||||
- **WHEN** человек открывает экран ревью
|
||||
- **THEN** панель действий печатает общее предложение подтвердить источник
|
||||
@@ -0,0 +1,115 @@
|
||||
# Задачи
|
||||
|
||||
## 1. Код
|
||||
|
||||
- [x] 1.1 В `internal/layout` завести константу предела длины компонента (255
|
||||
байт) с комментарием, называющим источник значения (`NAME_MAX` у
|
||||
ext4/xfs/btrfs) и обе стороны риска.
|
||||
- [x] 1.2 Завести `layout.ErrNameTooLong` рядом с `ErrCollision`; текст ошибки
|
||||
называет непомещающийся компонент и его длину в байтах.
|
||||
- [x] 1.3 В `BuildLinks` проверять длину компонентов целевого пути **под корнем
|
||||
библиотеки** — после проверки `underRoot`, до возврата ссылки. Отказ
|
||||
целиком (`return nil, err`), как для прочей невалидности плана.
|
||||
- [x] 1.4 В `worker.linkPlan` добавить ветку `errors.Is(err, layout.ErrNameTooLong)`
|
||||
→ `review` с новым кодом причины `name_too_long`, перед общей веткой
|
||||
`reasonBuild`. Код завести в своде причин рядом с `reasonCollision`.
|
||||
|
||||
- [x] 1.5 Панель действий ревью при пустом предпросмотре печатает записанную
|
||||
причину загрузки, а при её отсутствии — прежний общий текст
|
||||
(`web/templates/partials/review_main.html`, поле вью в
|
||||
`internal/httpapi/review.go`).
|
||||
|
||||
## 2. Тесты
|
||||
|
||||
- [x] 2.1 `BuildLinks` с названием, дающим имя файла длиннее предела, возвращает
|
||||
ошибку, обёртывающую `ErrNameTooLong`; ни одного каталога на диске не
|
||||
появилось.
|
||||
- [x] 2.2 То же для папки тайтла: название укладывается в имя файла, но папка с
|
||||
годом и provider-тегом длиннее предела.
|
||||
- [x] 2.3 Проверка считает **байты**, а не руны: кириллическое название той же
|
||||
длины в знаках упирается вдвое раньше латинского. Тест на границе:
|
||||
помещается ровно 255 байт, 256 — нет.
|
||||
- [x] 2.4 Путь, выходящий за библиотеку, и одновременно слишком длинное имя дают
|
||||
ошибку **выхода за библиотеку**, а не длины (приоритет проверок).
|
||||
- [x] 2.5 `worker`: раскладка с непомещающимся именем уводит задачу в `review` с
|
||||
кодом `name_too_long`, каталог цели пуст, в `error_msg` нет текста
|
||||
системной ошибки.
|
||||
- [x] 2.6 Раскладка нормальной длины ведёт себя как прежде (регрессия на
|
||||
существующих тестах пакета).
|
||||
- [x] 2.7 Название не обрезается и не переименовывается: на непомещающемся входе
|
||||
ни одной ссылки не возвращено, а не возвращена усечённая.
|
||||
- [x] 2.8 Мерится финальный компонент: имя, проходящее впритык, не растёт ни на
|
||||
одном шаге сборки — суффикс субтитров (`.ru.forced`) на границе отклоняется.
|
||||
- [x] 2.9 Вырожденные входы не роняют проверку: пустая строка, одни пробелы,
|
||||
невалидный UTF-8, очень длинная строка.
|
||||
- [x] 2.10 Панель действий: с записанной причиной и пустым предпросмотром
|
||||
печатается причина; без причины — прежний общий текст.
|
||||
|
||||
## 4. Правки по ревью кода
|
||||
|
||||
- [x] 4.1 Причина пустого предпросмотра считается **на показе**
|
||||
(`ReviewData.PreviewError`) и предпочитается записанной: задача без
|
||||
записанной причины иначе оставляет экран без объяснения, а после смены
|
||||
источника записанная устаревает.
|
||||
- [x] 4.2 Карточка Telegram называет ту же причину, когда плана в ней нет; в
|
||||
колбэке — своя ветка ответа для `ErrNameTooLong`, как у коллизии.
|
||||
- [x] 4.3 `logCmd`: `ErrNameTooLong` — штатный отказ, `DEBUG`, а не `ERROR`.
|
||||
- [x] 4.4 Реестры: строка в таблицу маппинга `docs/conventions/errors.md`,
|
||||
константа предела в блок числовых пределов `docs/database.md`, клауза про
|
||||
409 — в дельту `review`.
|
||||
- [x] 4.5 Текст причины несёт имя усечённым (`shorten`, 40 рун), точную длину —
|
||||
числом: имя целиком занимало пол-экрана и оседало в БД.
|
||||
- [x] 4.6 Сценарий «унаследованная база» переписан: случай недостижим — папка-якорь
|
||||
≤ предела, а хвост имени файла не длиннее хвоста имени папки. Свойство
|
||||
закреплено тестом.
|
||||
- [x] 4.7 Тесты: `PreviewError` при пустой причине состояния; подсказка доводит до
|
||||
`done`; унаследованная база в предел помещается; карточка Telegram и колбэк;
|
||||
`shorten`; кириллическая граница добита, неверный комментарий исправлен.
|
||||
|
||||
## 3. Гейт и спеки
|
||||
|
||||
- [x] 3.1 `openspec validate --strict long-title-to-review` — зелёный.
|
||||
- [x] 3.2 `task gate` — зелёный, покрытие изменённых строк полное.
|
||||
|
||||
## Критерии приёмки (из постановки)
|
||||
|
||||
- **A1.** Раздача с именем в 250 байт уходит в `review` с доменной причиной, а не
|
||||
в `failed` с текстом ядра. **Оракул:** тест на временном каталоге прогона,
|
||||
воспроизводящий случай из «Воспроизведения», — проверяет состояние задачи и код
|
||||
причины.
|
||||
- **A2.** Ни одна ссылка не создана до отказа: частичной раскладки батча не
|
||||
остаётся. **Оракул:** тот же тест — проверяет, что каталог цели пуст.
|
||||
- **A3.** Причина, показанная человеку, не содержит текста системной ошибки.
|
||||
**Оракул:** утверждение теста на текст причины плюс конвенция
|
||||
`docs/conventions/errors.md` — перевод доменной ошибки на внешней границе.
|
||||
- **A4.** Проверка длины стоит **до** первой операции с файловой системой.
|
||||
**Оракул:** чтение диффа на ревью; тест на пустоту каталога цели его
|
||||
подтверждает.
|
||||
|
||||
## Приёмочные критерии из рубрики (ревью дизайна)
|
||||
|
||||
Свойства, порождённые до чтения кода. Каждое проверяется тестом или чтением
|
||||
диффа; те, что уже покрыты задачами выше, названы ссылкой на них.
|
||||
|
||||
- **R1.** Единица предела — байты UTF-8, не руны (задача 2.3).
|
||||
- **R2.** Отказ целиком, без самостоятельного усечения (задачи 2.1, 2.7).
|
||||
- **R3.** Мерится ровно та строка, что уйдёт в `mkdir`/`link`, — после
|
||||
санитизации и всех суффиксов (задача 2.8).
|
||||
- **R4.** Приоритет над проверкой песочницы (задача 2.4).
|
||||
- **R5.** Исход отказа определён и починим человеком; случай, где подсказка не
|
||||
помогает, назван спекой отдельным сценарием, а не умолчанием.
|
||||
- **R6.** Вердикт — функция от уже зафиксированного, а не от порядка загрузок.
|
||||
Держится арифметикой: унаследованная от живой папки база отказа по длине дать
|
||||
не может, поэтому наличие якоря на исход не влияет
|
||||
(`TestApply_InheritedBaseAtLimitStillFits`).
|
||||
- **R7.** Превью и применение дают одинаковый вердикт по одному входу — потому
|
||||
что зовут одну функцию, а не потому, что так договорились.
|
||||
- **R8.** Константа предела несёт провенанс и обе стороны ошибки (задача 1.1).
|
||||
- **R9.** Предел пути целиком (`PATH_MAX`) назван и исключён с посчитанной
|
||||
причиной — в `design.md`, Non-Goals.
|
||||
- **R10.** Детерминизм и чистота: проверка не ходит на диск, не спрашивает ядро,
|
||||
ни один вход не даёт паники (задача 2.9).
|
||||
- **R11.** Отказ наблюдаем один раз и без чужого текста: логирующий чекпоинт
|
||||
один, текст причины без системной ошибки и без секретов (задача 2.5).
|
||||
- **R12.** Регрессия: нормальная раскладка, идемпотентный повтор и copy-fallback
|
||||
ведут себя как прежде (задача 2.6).
|
||||
@@ -283,3 +283,72 @@ Jellyfin на состояние задачи влиять SHALL NOT — оши
|
||||
- **WHEN** задача входит в состояние вне `{done, reverted, deleted}` (например, `review` или промежуточный `target_missing`)
|
||||
- **THEN** система скан не дёргает
|
||||
|
||||
### Requirement: Непомещающееся целевое имя уходит в review
|
||||
|
||||
Система SHALL отклонять раскладку целиком, если хотя бы один компонент целевого
|
||||
пути (папка тайтла, папка сезона, имя файла с расширением и суффиксами) длиннее
|
||||
предела, и SHALL переводить задачу в `review` с доменной причиной, а не в
|
||||
`failed`. Проверка SHALL выполняться до первой операции с файловой системой: ни
|
||||
каталога, ни ссылки при отказе не создаётся. Причина SHALL нести собственный код,
|
||||
отличный и от кода коллизии, и от общего кода отказа построения плана, а
|
||||
человекочитаемый текст SHALL называть непомещающийся компонент и НЕ SHALL
|
||||
содержать текста системной ошибки.
|
||||
|
||||
Предел SHALL меряться в **байтах** UTF-8-представления имени, а не в символах:
|
||||
кириллическое название упирается вдвое раньше латинского той же длины в знаках.
|
||||
Величина — **255 байт** (`NAME_MAX` у ext4/xfs/btrfs); она фиксирована и у ядра не
|
||||
выясняется, потому что раскладка обязана отказать до обращения к диску. На
|
||||
файловой системе с меньшим пределом остаётся сегодняшний исход — отказ ядра и
|
||||
`failed`; это осознанный остаток, а не пробел.
|
||||
|
||||
Проверка длины SHALL выполняться **после** проверки нахождения пути под корнем
|
||||
библиотеки: путь, вышедший за песочницу, SHALL отклоняться как выход за
|
||||
библиотеку, иначе находка безопасности спряталась бы за косметической причиной.
|
||||
|
||||
Самостоятельно обрезать или переименовывать название система НЕ SHALL — это
|
||||
решение человека.
|
||||
|
||||
#### Scenario: Слишком длинное имя файла уходит в review
|
||||
|
||||
- **GIVEN** распознавание даёт название, из которого имя целевого файла
|
||||
складывается длиннее 255 байт
|
||||
- **WHEN** выполняется раскладка
|
||||
- **THEN** задача переходит в `review` с причиной «имя не помещается», ни один
|
||||
каталог и ни одна ссылка не созданы, а текст системной ошибки человеку не
|
||||
показан
|
||||
|
||||
#### Scenario: Предел меряется в байтах
|
||||
|
||||
- **GIVEN** два названия одинаковой длины в знаках — латинское и кириллическое
|
||||
- **WHEN** строятся целевые пути
|
||||
- **THEN** кириллическое отклоняется вдвое раньше латинского, а граница
|
||||
проходит между 255 и 256 байтами
|
||||
|
||||
#### Scenario: Слишком длинное имя папки тайтла уходит в review
|
||||
|
||||
- **GIVEN** название укладывается в имя файла, но папка тайтла с годом и
|
||||
provider-тегом длиннее предела
|
||||
- **WHEN** выполняется раскладка
|
||||
- **THEN** задача переходит в `review` с той же причиной, каталог тайтла не
|
||||
создан
|
||||
|
||||
#### Scenario: Выход за библиотеку важнее длины
|
||||
|
||||
- **GIVEN** целевой путь одновременно выходит за корень библиотеки и длиннее
|
||||
предела
|
||||
- **WHEN** строятся целевые пути
|
||||
- **THEN** отказ называет выход за библиотеку, а не длину имени
|
||||
|
||||
#### Scenario: Подсказка человека чинит случай
|
||||
|
||||
- **GIVEN** задача в `review` с причиной «имя не помещается»
|
||||
- **WHEN** человек задаёт название короче и применяет раскладку заново
|
||||
- **THEN** раскладка проходит штатно и задача переходит в `done`
|
||||
|
||||
#### Scenario: Унаследованная база в предел помещается всегда
|
||||
|
||||
- **GIVEN** база имени унаследована от живой папки-якоря по правилу сходимости
|
||||
- **WHEN** строятся имена файлов внутри этой папки
|
||||
- **THEN** они помещаются в предел, потому что папка-якорь лежит на диске и уже
|
||||
не длиннее предела, а хвост имени файла не длиннее хвоста имени папки
|
||||
|
||||
|
||||
@@ -413,3 +413,65 @@ htmx-фрагмента (`GET /fragments/downloads/{id}/review`) и по зав
|
||||
- **THEN** обработчик исполняет то же доменное действие и отвечает редиректом на
|
||||
`/review/{id}`, поведение без JavaScript не ломается
|
||||
|
||||
### Requirement: Панель действий при пустом предпросмотре называет причину
|
||||
|
||||
Панель действий SHALL называть причину, когда предпросмотр раскладки пуст, а не
|
||||
печатать общее «Подтверди источник, чтобы получить превью раскладки». Первой
|
||||
SHALL идти причина, посчитанная **на показе** — отказ построения этого
|
||||
предпросмотра: она относится к текущему эффективному плану, тогда как записанная
|
||||
при последнем переходе после смены источника устаревает, а у задачи, пришедшей в
|
||||
`review` без записанной причины, её нет вовсе. Записанная причина SHALL
|
||||
использоваться, когда посчитанной нет. Общий текст SHALL оставаться только там,
|
||||
где нет ни той, ни другой — источник действительно ещё не подтверждён.
|
||||
|
||||
Построение предпросмотра НЕ SHALL двигать состояние задачи: причина считается на
|
||||
чтении и наружу отдаётся значением, а не записью.
|
||||
|
||||
Те же две причины в том же порядке SHALL показываться и в карточке Telegram,
|
||||
когда плана в ней нет: обе поверхности ревью объясняют отсутствие команды
|
||||
«Применить» одинаково. Команда, упершаяся в непомещающееся имя, SHALL отвечать
|
||||
конфликтом, а не сбоем сервера.
|
||||
|
||||
Требование не трогает доступность команды «Применить»: она по-прежнему следует
|
||||
наличию предпросмотра. Речь о том, что человеку говорят, когда предпросмотра нет:
|
||||
пустой предпросмотр наступает и от коллизии путей, и от непомещающегося имени, и
|
||||
от невалидного плана, а текст сегодня во всех случаях один и в трёх из четырёх
|
||||
неверен.
|
||||
|
||||
#### Scenario: Непомещающееся имя названо в панели действий
|
||||
|
||||
- **GIVEN** загрузка в `review` с причиной «имя не помещается», источник
|
||||
подтверждён, предпросмотр пуст
|
||||
- **WHEN** человек открывает экран ревью
|
||||
- **THEN** панель действий печатает причину отказа, а не предложение подтвердить
|
||||
источник, и команда «Применить» недоступна
|
||||
|
||||
#### Scenario: Причина не записана в состоянии — считается на показе
|
||||
|
||||
- **GIVEN** загрузка пришла в `review` без записанной причины (нет матча), а её
|
||||
название не помещается в имя файла
|
||||
- **WHEN** человек открывает экран ревью
|
||||
- **THEN** панель действий называет длину имени, хотя в состоянии причины нет, и
|
||||
состояние при этом не меняется
|
||||
|
||||
#### Scenario: После смены источника показывается свежая причина
|
||||
|
||||
- **GIVEN** загрузка в `review` с записанной причиной «имя не помещается», и
|
||||
человек выбрал другой источник
|
||||
- **WHEN** экран перестраивается
|
||||
- **THEN** показывается причина, посчитанная для нового плана, а не записанная
|
||||
при прошлом переходе
|
||||
|
||||
#### Scenario: Карточка Telegram называет ту же причину
|
||||
|
||||
- **GIVEN** загрузка в `review`, плана в карточке нет
|
||||
- **WHEN** карточка отправляется или обновляется
|
||||
- **THEN** в ней есть строка с причиной, по которой план не построился
|
||||
|
||||
#### Scenario: Источник не подтверждён — текст прежний
|
||||
|
||||
- **GIVEN** загрузка в `review` без записанной причины, без посчитанной и без
|
||||
предпросмотра
|
||||
- **WHEN** человек открывает экран ревью
|
||||
- **THEN** панель действий печатает общее предложение подтвердить источник
|
||||
|
||||
|
||||
Reference in New Issue
Block a user