docs: правила о контексте и о токене записаны, две находки — в журнал
- в go-linters.md заведён раздел «Отмена и внешний собеседник», перечень механизированного пополнен девятью правилами и двумя шагами гейта, названы остатки: contextcheck не видит сигнатуру без контекста вовсе, шаг migrations судит только шаги, бывшие в базе диффа, rowserrcheck и sqlclosecheck профилактические — предмета в коде нет - в logging.md и security.md чистка отказа Telegram описана по факту: точка одна и лежит на границе клиента, закрыты все пять путей вместе с логгером самой библиотеки. Прежнее «*Расхождение:* вычистки нет... она не логируется» было неверным дважды - в журнал дефектов записаны две находки: отказ скачивания уносил токен бота (проскочил, жил с самого начала) и остановка сервиса хоронила конвертируемую запись в failed (поймано ревью до коммита) - вопрос темы operations про отмену переформулирован: спрашивать надо не «доходит ли контекст», а «что шаг делает с задачей, деньгами и ответом отправителю»; вопрос про таймаут оставлен с оговоркой, что проброс контекста на него не отвечает - в памятке: словарь кодов новых шагов, требование компилятора C у детектора гонок и оговорка, что «CGO не нужен» относится к сборке, а не к гейту
This commit is contained in:
@@ -24,7 +24,7 @@ Yandex SpeechKit и возвращает текст туда, откуда пр
|
|||||||
|
|
||||||
## Стек
|
## Стек
|
||||||
|
|
||||||
Go 1.26 (CGO не нужен), встроенная PocketBase — хранилище, файлы записей и
|
Go 1.26 (сборке CGO не нужен; детектору гонок в гейте — нужен), встроенная PocketBase — хранилище, файлы записей и
|
||||||
панель администратора, — `go-telegram-bot-api`, `aws-sdk-go-v2` для Object
|
панель администратора, — `go-telegram-bot-api`, `aws-sdk-go-v2` для Object
|
||||||
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка —
|
Storage, gRPC-клиент Yandex SpeechKit v3, Prometheus, `slog`. Сборка —
|
||||||
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
|
Taskfile, образ — Docker, выкладка — Ansible из `pet-project-server`.
|
||||||
@@ -87,7 +87,7 @@ Taskfile, образ — Docker, выкладка — Ansible из `pet-project-
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
go build ./... # CGO не нужен
|
go build ./... # CGO не нужен
|
||||||
go test ./...
|
go test ./... # в гейте идёт с -race, и там нужен компилятор C
|
||||||
go vet ./...
|
go vet ./...
|
||||||
gofmt -l .
|
gofmt -l .
|
||||||
golangci-lint run
|
golangci-lint run
|
||||||
@@ -117,13 +117,19 @@ task gate # весь набор проверок разом
|
|||||||
найден), 4 внутренний сбой. Последний своего словаря не заводит намеренно:
|
найден), 4 внутренний сбой. Последний своего словаря не заводит намеренно:
|
||||||
четвёртый шаг с собственной семантикой сделал бы это утверждение неверным.
|
четвёртый шаг с собственной семантикой сделал бы это утверждение неверным.
|
||||||
Тому же словарю следуют **обёртки шагов** в `Taskfile.yml` — все, включая
|
Тому же словарю следуют **обёртки шагов** в `Taskfile.yml` — все, включая
|
||||||
`shell`, `dockerfile` и `vulns`: недостающий инструмент — отказ окружения, код
|
`tests`, `migrations`, `shell`, `dockerfile` и `vulns`: недостающий инструмент
|
||||||
3. Сами чужие инструменты (`shellcheck`, `hadolint`, `govulncheck`,
|
— отказ окружения, код 3. У `tests` это отсутствие CGO или компилятора C, без
|
||||||
|
которых не работает детектор гонок — тесты он в этом случае всё равно гоняет,
|
||||||
|
без `-race`, и краснеет уже после них. У `migrations` код 3 — неразрешимая
|
||||||
|
база диффа, отсутствующий каталог шагов и каталог без единого шага; код 1 —
|
||||||
|
переписанный шаг схемы. Сами чужие инструменты (`shellcheck`, `hadolint`, `govulncheck`,
|
||||||
`golangci-lint`) держат свои коды, и гейту от них нужно только «ненулевой».
|
`golangci-lint`) держат свои коды, и гейту от них нужно только «ненулевой».
|
||||||
Недостающий скрипт — отказ окружения, код 3. Наружу все эти коды приходят одним: сам `task` на
|
Недостающий скрипт — отказ окружения, код 3. Наружу все эти коды приходят одним: сам `task` на
|
||||||
любой отказ шага выходит с 201, а код шага печатает строкой
|
любой отказ шага выходит с 201, а код шага печатает строкой
|
||||||
(«exit status 3»), поэтому словарь читается по коду скрипта.
|
(«exit status 3»), поэтому словарь читается по коду скрипта.
|
||||||
- **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`,
|
- **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`,
|
||||||
|
гонка, найденная детектором (`go test -race`), переписанный применённый шаг
|
||||||
|
схемы,
|
||||||
неотформатированный файл, находка `golangci-lint`, расхождение объявленных
|
неотформатированный файл, находка `golangci-lint`, расхождение объявленных
|
||||||
версий Go, дрейф раскладки документов,
|
версий Go, дрейф раскладки документов,
|
||||||
дрейф каталога задач, форма `openspec/config.yaml`, достижимая из кода
|
дрейф каталога задач, форма `openspec/config.yaml`, достижимая из кода
|
||||||
|
|||||||
@@ -93,8 +93,11 @@
|
|||||||
## Внешние границы и форматы
|
## Внешние границы и форматы
|
||||||
|
|
||||||
- **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения.
|
- **Telegram Bot API.** Вход — обновления длинным опросом, выход — сообщения.
|
||||||
Файл скачивается по ссылке `file.Link(token)` обычным `http.Get`. Telegram не
|
Файл скачивается по ссылке `file.Link(token)` запросом с контекстом, клиентом
|
||||||
отдаёт файлы больше 20 МиБ — это потолок приёма из бота.
|
самого бота. Клиента заводит единая точка `internal/adapter/telegram`: токен
|
||||||
|
стоит в пути каждого обращения, и снятие адреса с отказа живёт там —
|
||||||
|
[conventions/logging.md](conventions/logging.md), «Безопасность: что не
|
||||||
|
логируем». Telegram не отдаёт файлы больше 20 МиБ — это потолок приёма из бота.
|
||||||
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
|
- **Yandex Object Storage.** S3-совместимый, клиент `aws-sdk-go-v2` с
|
||||||
`UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением.
|
`UsePathStyle`. Ключ объекта — имя файла, то есть UUID с расширением.
|
||||||
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
|
- **Yandex SpeechKit v3.** gRPC, `stt.api.cloud.yandex.net:443`, модель
|
||||||
@@ -118,8 +121,9 @@
|
|||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| Telegram Bot API | Бот не стартует, приложение продолжает работу без него | Скачивание файла висит бесконечно | Длинный опрос пуст, новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
|
| Telegram Bot API | Бот не стартует, приложение продолжает работу без него | Скачивание файла висит бесконечно | Длинный опрос пуст, новые задачи не заводятся | Файл скачался битым, отказ вылезет на конвертации |
|
||||||
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
|
| Yandex SpeechKit | Шаг возвращает ошибку, задача остаётся на повтор | Захват держится час, задача не двигается | Операция вечно `in progress`, повтор каждые 5 секунд | Пустой текст — задача завершается заглушкой «на записи нет текста» |
|
||||||
|
| ↳ *остановка сервиса* | Принятие операции от отмены защищено своим пределом в 10 секунд: операцию там могли принять и начать считать деньги, а потерянный идентификатор заставил бы повтор оплатить ту же запись второй раз. Заливка в Object Storage отменяется штатно — её повтор бесплатен, объект ложится под тем же ключом | — | — | — |
|
||||||
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
| Yandex Object Storage | Заливка падает, задача остаётся в `converted` | То же, что падение: висит до конца захвата | — | SpeechKit не прочитает объект и вернёт отказ операции |
|
||||||
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла» | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
|
| ffmpeg, ffprobe | Задача уходит в `failed` с текстом «сбой конвертации файла». Остановка сервиса — исход другой: процесс убивают контекстом, и задача остаётся на повтор, не тратя попытки | Конвейер стоит: вызов синхронный | — | Выходной файл пуст, отказ вылезет на распознавании |
|
||||||
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
|
| Хранилище (файл на диске) | Приложение не стартует либо шаг падает на каждом запросе | Блокировка записи держит воркеры | — | — |
|
||||||
| Диск | Запись файла падает, задача не заводится | — | — | — |
|
| Диск | Запись файла падает, задача не заводится | — | — | — |
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,11 @@ Go-проект как есть. Своё здесь — перечень пра
|
|||||||
версий Go поведение нормировано отдельно, спекой
|
версий Go поведение нормировано отдельно, спекой
|
||||||
[toolchain](../../openspec/specs/toolchain/spec.md): это единственная проверка
|
[toolchain](../../openspec/specs/toolchain/spec.md): это единственная проверка
|
||||||
проекта, у которой есть своя capability, и потому единственная, чьи сценарии
|
проекта, у которой есть своя capability, и потому единственная, чьи сценарии
|
||||||
проверяются построчно (`scripts/check_go_version_test.go`).
|
проверяются построчно (`scripts/check_go_version_test.go`). Второй самодельный
|
||||||
|
шаг — `migrations` — нормы не имеет: он проверен мутацией на трёх исходах
|
||||||
|
(переписанный шаг, пустой каталог, чистое дерево), но регрессионных проверок у
|
||||||
|
него нет, и дрейф его собственного шаблона имени никто не поймает. Это
|
||||||
|
объявленный долг, а не умолчание.
|
||||||
|
|
||||||
## Лестница механизации
|
## Лестница механизации
|
||||||
|
|
||||||
@@ -104,6 +108,9 @@ Go-проект как есть. Своё здесь — перечень пра
|
|||||||
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml` → `errorlint` |
|
| Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml` → `errorlint` |
|
||||||
| Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules` → `TestОшибкаНеУзнаётсяПоТексту` |
|
| Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules` → `TestОшибкаНеУзнаётсяПоТексту` |
|
||||||
| Непроверенное возвращаемое значение ошибки | `.golangci.yml` → `errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close`, `os.Remove` и `send` |
|
| Непроверенное возвращаемое значение ошибки | `.golangci.yml` → `errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close`, `os.Remove` и `send` |
|
||||||
|
| Непроверенное приведение типа (`v := x.(T)`) | `.golangci.yml` → `errcheck` с `check-type-assertions`. Отдельная настройка, потому что такое приведение паникует, а не возвращает ошибку, и `check-blank` его не видит |
|
||||||
|
| Проверенный отказ не оборачивается в `return nil` | `.golangci.yml` → `nilerr`. Механизирует половину инварианта «принятая запись не теряется молча»: молчаливый успех после отказа |
|
||||||
|
| Отказ выборки из хранилища не теряется (`rows.Err()`), а сама выборка закрывается | `.golangci.yml` → `rowserrcheck`, `sqlclosecheck`. **Профилактические: предмета в коде сегодня нет** — выборки идут через `dbx` хранилища, а из `database/sql` употребляются только `sql.NullString` и `sql.ErrNoRows`. Правила заведены на будущий сырой запрос; мутацией проверены на пробе, а не на своём коде |
|
||||||
| Ошибки — только stdlib, без сторонних пакетов | `.golangci.yml` → `depguard` |
|
| Ошибки — только stdlib, без сторонних пакетов | `.golangci.yml` → `depguard` |
|
||||||
|
|
||||||
### Структура и границы
|
### Структура и границы
|
||||||
@@ -115,6 +122,14 @@ Go-проект как есть. Своё здесь — перечень пра
|
|||||||
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules` → `TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
|
| Адаптер не знает ни ядра, ни транспортов | `internal/archrules` → `TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` |
|
||||||
| Колонки очереди согласованы: перечень захвата ↔ структура захвата ↔ шаг схемы ↔ запись коллекции ↔ перенос поля в задачу | `internal/archrules` → четыре правила о захвате. Закрывает инвариант «колонки правятся в четырёх местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций: по файлу целиком условие выполнялось бы тегами `db:"…"` самой структуры, и правило было бы зелёным всегда |
|
| Колонки очереди согласованы: перечень захвата ↔ структура захвата ↔ шаг схемы ↔ запись коллекции ↔ перенос поля в задачу | `internal/archrules` → четыре правила о захвате. Закрывает инвариант «колонки правятся в четырёх местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций: по файлу целиком условие выполнялось бы тегами `db:"…"` самой структуры, и правило было бы зелёным всегда |
|
||||||
|
|
||||||
|
### Отмена и внешний собеседник
|
||||||
|
|
||||||
|
| Правило | Где механизировано |
|
||||||
|
| --- | --- |
|
||||||
|
| Запрос и внешний процесс заводятся с контекстом (`exec.CommandContext`, `http.NewRequestWithContext`, `QueryContext`) | `.golangci.yml` → `noctx`. Единая точка не нужна: контекст приезжает доводом, а контракты `internal/contract` несут его первым |
|
||||||
|
| Контекст приезжает сверху, а не заводится по месту (`context.Background()` в середине цепочки) | `.golangci.yml` → `contextcheck` |
|
||||||
|
| Тело ответа HTTP закрывается | `.golangci.yml` → `bodyclose`. Отдельно от `errcheck`: там `(io.ReadCloser).Close` объявлен исключением, и незакрытое тело от невыясненного `Close` неотличимо |
|
||||||
|
|
||||||
### Время, вывод, конфигурация
|
### Время, вывод, конфигурация
|
||||||
|
|
||||||
| Правило | Где механизировано |
|
| Правило | Где механизировано |
|
||||||
@@ -130,6 +145,9 @@ Go-проект как есть. Своё здесь — перечень пра
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml` → `forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` |
|
| Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml` → `forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` |
|
||||||
| Каждый сценарий нормы шага сверки версий проверен мутацией, а не памятью | `scripts/check_go_version_test.go` — 20 сценариев спеки `toolchain` плюс два свойства самого шага: исход не зависит от установленного `go`, и шаг не зовёт ни `go`, ни `docker`, ни сеть |
|
| Каждый сценарий нормы шага сверки версий проверен мутацией, а не памятью | `scripts/check_go_version_test.go` — 20 сценариев спеки `toolchain` плюс два свойства самого шага: исход не зависит от установленного `go`, и шаг не зовёт ни `go`, ни `docker`, ни сеть |
|
||||||
|
| Форма утверждения в проверках: «ожидалось» и «получено» не перепутаны местами, отказ судится `NoError`, а не `Nil`, `require` не зовут из горутины | `.golangci.yml` → `testifylint` |
|
||||||
|
| Одновременный доступ проверен детектором, а не чтением кода | `Taskfile.yml` → шаг `tests` (`go test -race ./...`). Общее у воркеров — счётчики метрик, логгер и клиент бота; захват задачи в гонку не входит, он по построению её не даёт (одно состояние на воркер) — см. «Типовые ложноположительные» в [../review.md](../review.md). Без компилятора C шаг гоняет тесты без детектора и краснеет кодом 3: гонки — не повод отнимать у гейта сами тесты |
|
||||||
|
| Строчное подавление называет линтер и причину, а протухшее краснеет | `.golangci.yml` → `nolintlint` (`require-explanation`, `require-specific`, `allow-unused: false`) |
|
||||||
|
|
||||||
### Форма кода и файлов вне Go
|
### Форма кода и файлов вне Go
|
||||||
|
|
||||||
@@ -146,6 +164,7 @@ Go-проект как есть. Своё здесь — перечень пра
|
|||||||
|
|
||||||
| Правило | Где механизировано |
|
| Правило | Где механизировано |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
| Применённый шаг схемы не переписывается: у файла шага допустим один статус — `A` | `Taskfile.yml` → шаг `migrations`. Закрывает инвариант CLAUDE.md (critical), которого не держит ни компилятор, ни хранилище: применённое считается по имени файла. Баз диффа две — `BASE` и `HEAD`: первая отвечает на «шаг уже уехал» ровно настолько, насколько свежа `origin/master`, вторая ловит правку закоммиченного шага независимо от неё. Каталог берётся из ключа `migrations` в `docs/.docs.json`, чтобы у факта не было второго дома; пустой каталог роняет шаг — правило, потерявшее предмет, молчать не должно. `migrations.go` под правило не подпадает: строка `Register` нового шага прибавляется именно там |
|
||||||
| Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` в `docs/.docs.json` |
|
| Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` в `docs/.docs.json` |
|
||||||
| Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` |
|
| Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` |
|
||||||
| Секреты в коммите | `lefthook.yml` → `gitleaks git --staged` |
|
| Секреты в коммите | `lefthook.yml` → `gitleaks git --staged` |
|
||||||
@@ -166,6 +185,7 @@ Go-проект как есть. Своё здесь — перечень пра
|
|||||||
| Правило о заголовках вне `*_test.go` | `.golangci.yml`, `exclusions` | В рабочем коде `Header()` и есть способ отдать заголовок |
|
| Правило о заголовках вне `*_test.go` | `.golangci.yml`, `exclusions` | В рабочем коде `Header()` и есть способ отдать заголовок |
|
||||||
| `time.Now` внутри `internal/clock` | там же | Единой точке чтения времени нечем читать время иначе |
|
| `time.Now` внутри `internal/clock` | там же | Единой точке чтения времени нечем читать время иначе |
|
||||||
| Чтение времени и окружения в `*_test.go` | там же | Проверка строит вход прогона — фикстуру времени, `PATH`, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре |
|
| Чтение времени и окружения в `*_test.go` | там же | Проверка строит вход прогона — фикстуру времени, `PATH`, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре |
|
||||||
|
| `noctx` на `httptest.NewRequest` в `*_test.go` | `.golangci.yml`, `exclusions` | Фикстура запроса к обработчику в том же процессе: внешнего собеседника за ней нет, отменять нечего. Изъятие названо по имени этой функции, а не выключением `noctx` на проверках: настоящий внешний вызов из проверки — `http.Get`, `exec.Command` — правилу по-прежнему подсуден, и это проверено мутацией |
|
||||||
| `SC1007` в `scripts/check-go-version.sh` | директива в скрипте | Ложное срабатывание на идиому `CDPATH= cd`, которая защищает `cd` от чужого `CDPATH` |
|
| `SC1007` в `scripts/check-go-version.sh` | директива в скрипте | Ложное срабатывание на идиому `CDPATH= cd`, которая защищает `cd` от чужого `CDPATH` |
|
||||||
| `DL3007` (`alpine:latest`) | `Taskfile.yml`, шаг `dockerfile` | Открытая задача `pin-runtime-image-base`; до её решения шаг краснел бы на известном |
|
| `DL3007` (`alpine:latest`) | `Taskfile.yml`, шаг `dockerfile` | Открытая задача `pin-runtime-image-base`; до её решения шаг краснел бы на известном |
|
||||||
| `DL3018` (закрепить версии `apk`) | там же | Alpine не держит старые версии пакетов в репозитории: закрепление ломает сборку через недели |
|
| `DL3018` (закрепить версии `apk`) | там же | Alpine не держит старые версии пакетов в репозитории: закрепление ломает сборку через недели |
|
||||||
@@ -185,6 +205,15 @@ Go-проект как есть. Своё здесь — перечень пра
|
|||||||
- вывод в stdout через `fmt.Fprintln(os.Stdout, …)` и `os.Stdout.WriteString`:
|
- вывод в stdout через `fmt.Fprintln(os.Stdout, …)` и `os.Stdout.WriteString`:
|
||||||
`forbidigo` судит по имени вызванной функции, а не по её первому аргументу;
|
`forbidigo` судит по имени вызванной функции, а не по её первому аргументу;
|
||||||
- проверка, судящая ответ мимо recorder — через свой `http.ResponseWriter`;
|
- проверка, судящая ответ мимо recorder — через свой `http.ResponseWriter`;
|
||||||
|
- **отсутствие** контекста у сигнатуры: `contextcheck` ловит обрыв цепочки —
|
||||||
|
`context.Background()` там, где контекст был доводом, — но метод, у которого
|
||||||
|
довода нет вовсе, правилу не виден. Первый проброс контекста в новый адаптер
|
||||||
|
остаётся человеку;
|
||||||
|
- шаг `migrations` судит только те шаги схемы, которые **есть в базе диффа**: у
|
||||||
|
добавленного после неё файла статус `A`, и правка такого файла законна — он
|
||||||
|
ещё никуда не уехал. Отсюда следствие: при отставшей `origin/master` правило
|
||||||
|
молчит на всём каталоге, и на подозрении база задаётся руками
|
||||||
|
(`task migrations BASE=<rev>`);
|
||||||
- направление «транспорт не знает адаптера»: сегодня оно нарушено осознанно —
|
- направление «транспорт не знает адаптера»: сегодня оно нарушено осознанно —
|
||||||
`controller/http` импортирует адаптер хранилища, потому что HTTP-поверхность и
|
`controller/http` импортирует адаптер хранилища, потому что HTTP-поверхность и
|
||||||
есть роутер этого хранилища. Изъятие названо в
|
есть роутер этого хранилища. Изъятие названо в
|
||||||
@@ -223,5 +252,11 @@ Go-проект как есть. Своё здесь — перечень пра
|
|||||||
принятое молчанием инструмента, — это не правило: прецеденты есть, и записаны
|
принятое молчанием инструмента, — это не правило: прецеденты есть, и записаны
|
||||||
они в [../review.md](../review.md) (журнал 2026-08-11 про недостижимую норму,
|
они в [../review.md](../review.md) (журнал 2026-08-11 про недостижимую норму,
|
||||||
2026-08-13 про обходимый текстовый запрет).
|
2026-08-13 про обходимый текстовый запрет).
|
||||||
|
|
||||||
|
**Мутация ставится по одному нарушению на строку.** `golangci-lint` печатает
|
||||||
|
с одной строки исходника **одну** находку (умолчание `uniq-by-line`), и
|
||||||
|
мутация, задевшая сразу два правила, покажет только первое: так молчали
|
||||||
|
`sqlclosecheck` и `rowserrcheck` на пробе, где та же строка уже краснела от
|
||||||
|
`noctx`. Проверять правило пробой, где оно единственное нарушенное.
|
||||||
5. **Записать строкой здесь** и удалить прозу из конвенции, если правило её
|
5. **Записать строкой здесь** и удалить прозу из конвенции, если правило её
|
||||||
заменило.
|
заменило.
|
||||||
|
|||||||
@@ -241,10 +241,22 @@ Object Storage, скачивание файла из Telegram и опрос оп
|
|||||||
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
|
проверка `errors.Is` на причину сохраняется. Общее правило: **секрет не кладём
|
||||||
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
|
в URL, если у сервиса есть заголовок** — тогда его нет и в ошибке транспорта.
|
||||||
|
|
||||||
*Расхождение:* вычистки нет. Скачивание файла из Telegram идёт обычным
|
Разговор с Telegram этому правилу следует, и точка чистки одна на все вызовы —
|
||||||
`http.Get(file.Link(token))`, и ошибка этого вызова содержит токен бота. Сегодня
|
`internal/adapter/telegram`, `NewBot`. Токен стоит в пути **каждого** обращения к
|
||||||
она не логируется — то есть утечки нет, но защищает от неё только отсутствие
|
Bot API, поэтому чистка на месте употребления закрывала бы один вызов из пяти:
|
||||||
строки лога.
|
|
||||||
|
- отказ транспорта разворачивает в первопричину клиент бота (`safeClient`), а
|
||||||
|
библиотека отдаёт наш отказ вызывающему нетронутым — этим закрыты `getFile`,
|
||||||
|
`sendMessage`, скачивание записи и `getMe` из конструктора;
|
||||||
|
- длинный опрос печатает свои отказы **пакетным логгером самой библиотеки**,
|
||||||
|
минуя наш `slog`; логгер подменён на вычищающий (`tgbotapi.SetLogger`), и
|
||||||
|
замена точная — токен известен.
|
||||||
|
|
||||||
|
Прежде здесь стоял `http.Get(file.Link(token))`, отказ уезжал в журнал вместе с
|
||||||
|
токеном, а конвенция числила это расхождением с оценкой «не логируется», которая
|
||||||
|
была неверной. Запись — [../review.md](../review.md), 2026-08-13; оракулом
|
||||||
|
служат проверки `internal/adapter/telegram/bot_test.go`, судящие по тексту
|
||||||
|
отказа и строке журнала.
|
||||||
|
|
||||||
*Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно
|
*Изъятие, а не расхождение:* расширение берётся из имени отправителя дословно
|
||||||
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
|
(`filepath.Ext`), поэтому имя `запись.тайное-слово` отдаёт приватный хвост
|
||||||
|
|||||||
+79
-5
@@ -116,12 +116,18 @@
|
|||||||
|
|
||||||
Форма: `<тема>: <вопрос> (<провенанс>)`.
|
Форма: `<тема>: <вопрос> (<провенанс>)`.
|
||||||
|
|
||||||
- `operations`: пережил ли шаг конвейера отмену контекста на середине — воркеры
|
- `operations`: как шаг отвечает на отмену посреди работы — контекст доходит до
|
||||||
получают `ctx`, но ни один шаг его внутрь не передаёт (чтение `worker.go` и
|
внешнего собеседника и это держат правила `noctx` и `contextcheck`
|
||||||
`transcribe.go`, 2026-08-10).
|
([conventions/go-linters.md](conventions/go-linters.md), «Отмена и внешний
|
||||||
|
собеседник»), а исход прерванного шага нормой по-прежнему не описан
|
||||||
|
(`openspec/specs/pipeline`, `Purpose`). Спрашивать надо не «доходит ли», а «что
|
||||||
|
делает с задачей, деньгами и ответом отправителю» (чтение `worker.go` и
|
||||||
|
`transcribe.go`, 2026-08-13; прежний провенанс 2026-08-10 устарел вместе с
|
||||||
|
дефектом «остановка хоронила запись»).
|
||||||
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
|
- `operations`: появился ли таймаут у обращения к Telegram, S3 и SpeechKit — ни у
|
||||||
одного из них таймаута нет (чтение `tg.go`, `s3.go`, `speechkit.go`,
|
одного из них таймаута нет, и проброс контекста на этот вопрос **не отвечает**:
|
||||||
2026-08-10).
|
контекст здесь несёт жизнь процесса, а не дедлайн вызова (чтение `tg.go`,
|
||||||
|
`s3.go`, `speechkit.go`, 2026-08-13).
|
||||||
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
|
- `operations`: не удвоилась ли запись об одном сбое — шаг логирует ошибку и
|
||||||
возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
|
возвращает её воркеру, который логирует снова (чтение `transcribe.go`,
|
||||||
2026-08-10).
|
2026-08-10).
|
||||||
@@ -240,6 +246,74 @@ API и имя не откатываются обратной правкой по
|
|||||||
поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и
|
поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и
|
||||||
выдумывать его задним числом нельзя.
|
выдумывать его задним числом нельзя.
|
||||||
|
|
||||||
|
## 2026-08-13 — остановка сервиса хоронила конвертируемую запись [пойман ревью]
|
||||||
|
|
||||||
|
- **Где:** `internal/service/transcribe.go`, шаг конвертации — дефект завела та
|
||||||
|
же правка, что проложила контекст до `ffmpeg`
|
||||||
|
- **Симптом:** на прод не уехал, поймали до коммита. Выглядел бы так: обычная
|
||||||
|
выкладка посреди конвертации переводит здоровую запись в терминальное
|
||||||
|
`failed`, отправителю уходит «сбой конвертации файла», а вернуть задачу может
|
||||||
|
только владелец правкой в панели. Окно — часы: конвертация шестичасовой записи
|
||||||
|
идёт дольше часа по построению
|
||||||
|
- **Причина:** контекст дошёл до внешнего процесса, а различать его отмену шаг
|
||||||
|
не научили. Убитый по контексту `ffmpeg` отдаёт `signal: killed` — от
|
||||||
|
настоящего отказа (`exit status N`) эта ошибка неотличима ни типом, ни
|
||||||
|
`errors.Is`: различает только `ctx.Err()`. Шаг звал `failJob` на любой отказ
|
||||||
|
`Convert`. Хуже: `failJob` возвращает `nil`, поэтому воркер считал прогон
|
||||||
|
успешным, и метрика владельца — та, которой он замечает отказы, — не
|
||||||
|
шевелилась
|
||||||
|
- **Чем воспроизведён:** проверкой `TestShutdownDuringConversionKeepsJobRetryable`
|
||||||
|
с подставным конвертером, ведущим себя как убитый процесс: отдаёт отказ, не
|
||||||
|
несущий `context.Canceled`. Мутация снята — без развилки проверка краснеет
|
||||||
|
- **Почему не поймали раньше:** правка выглядела механической, «линтер потребовал
|
||||||
|
контекст». Цена оказалась в семантике очереди, а не в сигнатурах: отмена стала
|
||||||
|
значить разное на соседних шагах одного конвейера. Ни один линтер такого не
|
||||||
|
видит — это заметили три прохода ревью независимо, и все три построили путь
|
||||||
|
- **Что меняем:** прерванный шаг приговора не выносит — задача остаётся на
|
||||||
|
повтор, попытку не тратит (счётчик, выросший при захвате, возвращают назад) и
|
||||||
|
отправителю о несуществующем сбое не сообщает. Воркер не считает остановку
|
||||||
|
отказом и не пишет о ней владельцу. Задача не забирается вовсе, если нас уже
|
||||||
|
остановили. Остаток объявлен: норма отмены в спеке `pipeline` не описана, и
|
||||||
|
открытая задача `context-cancel-in-pipeline` этим закрыта не целиком
|
||||||
|
|
||||||
|
## 2026-08-13 — отказ скачивания уносил токен бота в журнал [проскочил]
|
||||||
|
|
||||||
|
- **Где:** `internal/controller/tg/tg.go`, скачивание записи по ссылке
|
||||||
|
`file.Link(c.bot.Token)`
|
||||||
|
- **Симптом:** не наблюдался, потому что журнал за этим местом никто не читал
|
||||||
|
построчно. Первый же сбой сети на скачивании писал в журнал
|
||||||
|
`Failed to download audio file` вместе с полным адресом запроса, а в адресе
|
||||||
|
Telegram держит токен бота (`…/bot<TOKEN>/…`). Инвариант «секрет не покидает
|
||||||
|
конфиг» помечен critical и необратим: утёкший токен отзывают руками
|
||||||
|
- **Причина:** `http.Get` возвращает `*url.Error`, и тот встраивает адрес
|
||||||
|
целиком. Отказ уходил в `fmt.Errorf("failed to download file: %w", err)`, а
|
||||||
|
оттуда — в `logger.Error` соседней строкой
|
||||||
|
- **Чем воспроизведён:** чтением цепочки от `http.Get` до вызова `logger.Error`
|
||||||
|
в трёх обработчиках; на живом боте не проверялся — боевым токеном запускаться
|
||||||
|
запрещено
|
||||||
|
- **Почему не поймали раньше:** правило было записано прозой и ровно про этот
|
||||||
|
случай — [conventions/logging.md](conventions/logging.md), «Ошибка
|
||||||
|
HTTP-транспорта несёт URL». Хуже: там же стояло объявленное *Расхождение* с
|
||||||
|
оценкой «сегодня она не логируется — то есть утечки нет», и оценка была
|
||||||
|
неверной. Строка лога существовала всё это время, но проза о ней не знала, а
|
||||||
|
машина прозу не проверяет
|
||||||
|
- **Что меняем:** чистка перенесена с места употребления на **границу клиента** —
|
||||||
|
`internal/adapter/telegram`, `NewBot`: свой `Do` разворачивает отказ в
|
||||||
|
первопричину, а подменённый логгер библиотеки вычищает токен из строк длинного
|
||||||
|
опроса, которые она печатает сама, мимо нашего `slog`. Транспорт бота токена
|
||||||
|
больше не получает: клиента ему отдают готовым. Расхождение в конвенции
|
||||||
|
закрыто, оценка в [security.md](security.md) исправлена
|
||||||
|
- **Чем закрыт от возврата:** проверками `internal/adapter/telegram/bot_test.go`
|
||||||
|
— четыре пути (`getFile`, `sendMessage`, конструктор, логгер библиотеки)
|
||||||
|
судятся по тексту отказа и строке журнала. Мутация снята: со снятой чисткой
|
||||||
|
три из них краснеют, печатая токен. Правило остаётся прозой (линтер не отличит
|
||||||
|
ссылку с секретом от ссылки без него), но у прозы теперь есть оракул
|
||||||
|
- **Как нашли:** первый путь — попутно, при разборе находок `noctx`: тот
|
||||||
|
потребовал переписать `http.Get` на запрос с контекстом, и цепочку пришлось
|
||||||
|
прочитать целиком. Остальные четыре — конвейером ревью в тот же день; правка,
|
||||||
|
закрывшая один путь, объявила класс закрытым в двух документах, и это едва не
|
||||||
|
осталось так
|
||||||
|
|
||||||
## 2026-08-13 — конец потока распознавания узнавался по тексту сообщения [пойман сканером]
|
## 2026-08-13 — конец потока распознавания узнавался по тексту сообщения [пойман сканером]
|
||||||
|
|
||||||
- **Где:** `internal/adapter/recognizer/yandex/speechkit.go`, чтение потока
|
- **Где:** `internal/adapter/recognizer/yandex/speechkit.go`, чтение потока
|
||||||
|
|||||||
+10
-2
@@ -267,8 +267,16 @@ Telegram отправителю.
|
|||||||
приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки
|
приходит путь, выданный самим Telegram. Настоящее имя документа дальше проверки
|
||||||
типа файла не идёт.
|
типа файла не идёт.
|
||||||
|
|
||||||
Токен бота попадает в URL скачивания файла (`file.Link(token)`), и этот URL
|
Токен бота стоит в пути **каждого** обращения к Bot API (`bot<TOKEN>/getFile`,
|
||||||
нигде не логируется.
|
`…/sendMessage`, `…/getMe`, `…/getUpdates`) и в ссылке на скачивание
|
||||||
|
(`file.Link(token)`). Сами адреса нигде не логируются, но до 2026-08-13 их
|
||||||
|
уносил **отказ транспорта**: `*url.Error` встраивает адрес целиком, а отказы
|
||||||
|
скачивания и отправки пишутся в журнал. Теперь адрес снимается на границе
|
||||||
|
клиента — `internal/adapter/telegram`, `NewBot`: свой `Do` чистит отказ, а
|
||||||
|
подменённый логгер библиотеки вычищает токен из строк длинного опроса, которые
|
||||||
|
она печатает сама. Транспорт бота токена больше не получает вовсе: клиента ему
|
||||||
|
отдают готовым. Правило — [conventions/logging.md](conventions/logging.md),
|
||||||
|
случай — [review.md](review.md), оракул — `internal/adapter/telegram/bot_test.go`.
|
||||||
|
|
||||||
## Что вне модели
|
## Что вне модели
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user