diff --git a/CLAUDE.md b/CLAUDE.md index b45a0da..a1a6d47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -115,17 +115,27 @@ task gate # весь набор проверок разом 2 ошибка употребления, 3 окружение (не корень проекта, каталог или файл не найден), 4 внутренний сбой. Последний своего словаря не заводит намеренно: четвёртый шаг с собственной семантикой сделал бы это утверждение неверным. - Тому же словарю следуют **обёртки шагов** в `Taskfile.yml`: недостающий скрипт - — отказ окружения, код 3. Наружу все эти коды приходят одним: сам `task` на + Тому же словарю следуют **обёртки шагов** в `Taskfile.yml` — все, включая + `shell`, `dockerfile` и `vulns`: недостающий инструмент — отказ окружения, код + 3. Сами чужие инструменты (`shellcheck`, `hadolint`, `govulncheck`, + `golangci-lint`) держат свои коды, и гейту от них нужно только «ненулевой». + Недостающий скрипт — отказ окружения, код 3. Наружу все эти коды приходят одним: сам `task` на любой отказ шага выходит с 201, а код шага печатает строкой («exit status 3»), поэтому словарь читается по коду скрипта. - **Что красит безусловно и почему:** отказ сборки, тестов, `go vet`, неотформатированный файл, находка `golangci-lint`, расхождение объявленных версий Go, дрейф раскладки документов, дрейф каталога задач, форма `openspec/config.yaml`, достижимая из кода - уязвимость в зависимостях (`govulncheck`). Машина проверяет всё + уязвимость в зависимостях (`govulncheck`), находка `shellcheck` в скриптах + оболочки и `hadolint` в `Dockerfile`. Машина проверяет всё перечисленное, и это не обсуждается. Шаг, чей скрипт не найден, краснеет с именем недостающего плагина, а не пропускается молча. +- **Что ловит pre-commit, а что только гейт.** `lefthook.yml` гоняет на + **затронутых файлах** дешёвую часть: `gofmt` (правит на месте и добавляет в + коммит), `golangci-lint` по пакетам тронутых файлов, `shellcheck`, `hadolint`, + `gitleaks` по индексу. Только гейту остаются сборка, `go vet`, тесты целиком, + сверка версий Go, три сверки документов и `govulncheck`: они смотрят всё + дерево либо требуют сети, а pre-commit обязан быть быстрым. - **Шагу `vulns` нужна сеть**, и он один такой: база уязвимостей живёт на vuln.go.dev. Без сети шаг краснеет, а не пропускается молча; сам инструмент ставится `go install golang.org/x/vuln/cmd/govulncheck@latest`. Судит он @@ -138,11 +148,10 @@ task gate # весь набор проверок разом которого образ перестаёт собираться, но собираемости не проверяет. Собрать образ по-прежнему может только человек — `task image`, и на подъёме версии это обязательно; - - `shellcheck` — shell-скрипт в гейте один, `scripts/check-go-version.sh`; три - соседних шага это Python в плагинах, и линтер оболочки к ним неприменим. - Линтер не заведён, и цена ему одна строка шага плюс одно подавление ложного - `SC1007` на идиому `CDPATH= cd`. Его не проверяет ничто, а это единственный - исполняемый файл проекта, которого не видят ни `go vet`, ни `golangci-lint`; + - собираемость `Dockerfile`: `hadolint` судит форму, а не сборку, и два его + правила подавлены поимённо — `DL3007` до задачи `pin-runtime-image-base` и + `DL3018` по существу (alpine не держит старые версии пакетов, закрепление + ломает сборку через недели). Причины стоят строками в `Taskfile.yml`; - `gitleaks` — висит на pre-commit в `lefthook.yml` и смотрит только индекс коммита. Полную историю никто не проверяет; - согласованность документов между собой и с кодом — её судят агенты, зовёт diff --git a/docs/architecture.md b/docs/architecture.md index bbf825b..6d9215f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,7 +56,13 @@ в работу»; здесь это принцип письма шага, а не описание поведения. - **Ядро зависит от интерфейсов.** `internal/service` знает только `internal/contract`; ffmpeg, Yandex, Telegram и хранилище подставляются в - `main.go`. + `main.go`. Правило механизировано тестами-сканерами `internal/archrules`, и + они же держат обратные направления: транспорты не знают друг о друге, адаптер + не знает ни ядра, ни транспортов. + *Изъятие:* транспорт **вправе** знать адаптер хранилища — `controller/http` + импортирует `adapter/repo/pocketbase`, потому что HTTP-поверхность и есть + роутер этого хранилища, а не наш сервер поверх него. Правила на это + направление нет намеренно. ## Компоненты @@ -136,13 +142,14 @@ | Переход задачи в состояние | `entity.TranscribeJob.MoveToState` — чистит служебные поля прошлого состояния | | Завершение и отказ | `TranscribeService.completeJob` и `failJob` — они же отвечают пользователю | | Разбор конфигурации | `internal/config.LoadConfig` | +| Чтение времени | `internal/clock` — `Now` даёт метку в UTC, `Start` — начало измерения длительности; `time.Now` вне пакета запрещён правилом линтера | | Метрики | `internal/metrics`, префикс имени `transcriber_` | | Значения метки формата | `internal/metrics.FormatLabel` — приводит расширение к закрытому перечню, прочее заменяет на `other`; нормирует спека `intake` | Единых точек, которых **нет** и которые ожидались бы: идентификаторы -генерируются вызовом `uuid.NewString()` по месту, время — вызовом `time.Now()` -по месту, отображения доменной ошибки в код HTTP-ответа нет — обработчик решает -сам. +генерируются вызовом `uuid.NewString()` по месту, отображения доменной ошибки в +код HTTP-ответа нет — обработчик решает сам. Время из этого перечня ушло +2026-08-13: его читает `internal/clock`, и запрет держит линтер. ## Деплой diff --git a/docs/autotests.md b/docs/autotests.md index ef6b87b..50e8fd2 100644 --- a/docs/autotests.md +++ b/docs/autotests.md @@ -1,8 +1,15 @@ # Автопроверки -Чем машина судит код: перечень свойств, доведённых до проверки, и место, где -каждое настроено. Документ — дом темы ревью `autotests`: проход, которому эта -тема досталась, читает его, а не перечисляет инструменты по памяти. +Чем машина судит код этого проекта: какие свойства доведены до проверки, чем +каждое проверяется, когда оно запускается и что остаётся человеку. Документ — +дом темы ревью `autotests`: проход, которому эта тема досталась, читает его, а не +перечисляет инструменты по памяти. + +Документ **переносимый**: устройство ниже — не особенность transcriber, а способ +вести автопроверки в Go-проекте, и разделы «Лестница механизации», «Два круга» и +«Как заводят новое правило» переносятся в другой проект как есть. Своё здесь — +перечень правил и подавлений; он назван так, чтобы отличать переносимое от +местного. Тема заведена 2026-08-13. Прежде перечень лежал разделом «Механизировано» в [conventions/README.md](conventions/README.md), и это был чужой дом: конвенции @@ -25,40 +32,189 @@ - **поведение сервиса** — нормативные спеки `openspec/specs/`. У шага сверки версий Go поведение нормировано отдельно, спекой [toolchain](../openspec/specs/toolchain/spec.md): это единственная проверка - проекта, у которой есть своя capability. + проекта, у которой есть своя capability, и потому единственная, чьи сценарии + проверяются построчно (`scripts/check_go_version_test.go`). -Домов настройки четыре: `.golangci.yml` — линтеры и форматтер, `lefthook.yml` — -проверки на pre-commit, `Taskfile.yml` — шаги гейта и их обёртки, `scripts/` — -единственный собственный скрипт проверки. Скрипты `docs.py`, `tasks.py` и -`openspec.py` живут вне репозитория, в плагинах, и Taskfile знает их путями. +## Лестница механизации + +Свойство поднимается по ступеням, и ступень выбирают не по вкусу, а по тому, +чем свойство выражается. Верхняя ступень дешевле нижней в эксплуатации и дороже +в заведении, поэтому прыгать через ступень без нужды не надо. + +1. **Проза конвенции.** Свойство названо словами, проверяет человек на каждом + ревью заново. Это ступень по умолчанию и худшая из всех: она стоит внимания + каждого прогона и молча перестаёт работать, когда внимание кончилось. +2. **Настройка готового линтера.** Свойство совпало с чужим правилом — + включается строкой в `.golangci.yml`. Дешевле всего; ограничение в том, что + правило чужое и говорит о том, о чём его написали. +3. **Запрет по имени** (`forbidigo`, `depguard`). Свойство выражается через «эту + функцию/пакет тут звать нельзя». Дешёво и точно, но требует **единой точки**, + куда запрещённое переносят: запрет без дома оставляет код без способа сделать + нужное. +4. **Тест-сканер исходников** (`internal/archrules`). Свойство — о структуре, а + не о вызове: направление зависимостей, согласованность двух перечней, + отсутствие идиомы. Пишется руками на `go/parser` или регулярном выражении, + зато читается как тест и ломается заметно. +5. **Свой шаг проверки** (`scripts/`, шаги `Taskfile.yml`). Свойство выходит за + пределы кода на Go: версия инструмента, форма `Dockerfile`, раскладка + документов. Дороже всех — у шага появляется своя норма и свои тесты. + +Ступень, выбранная неверно, видна сразу. Запрет по имени, обходимый одной +лишней строкой, — это ступень 4, наряженная третьей: так было с правилом о +заголовках ответа, которое сначала запретило текст `\.Header\(\)\.Get`, а +обходилось присваиванием в переменную. Правило переписано на суждение **по типу +приёмника** (`analyze-types`), и это уже настоящая третья ступень. + +## Два круга: pre-commit и гейт + +Проверки идут двумя кругами, и круг выбирается по цене прогона. + +| | pre-commit (`lefthook.yml`) | гейт (`task gate`) | +| --- | --- | --- | +| Когда | на каждый коммит | перед тем как считать задачу сделанной | +| На чём | на **затронутых файлах** | на всём дереве | +| Сколько идёт | около секунды | десятки секунд | +| Что делает с находкой | `gofmt` правит и добавляет в коммит, прочее роняет коммит | роняет прогон | + +Перечень работ pre-commit и то, что остаётся только гейту, — в +[CLAUDE.md](../CLAUDE.md), раздел «Гейт». Здесь важен принцип: **pre-commit не +подменяет гейт**. Он ловит дешёвое и местное, а сборка, тесты целиком, сверки +документов и запрос к базе уязвимостей идут в гейте — иначе коммит стоил бы +минуту, и хук отключили бы через день. + +Полный набор проверок в pre-commit не переносится сознательно; обратное решение +— «гонять всё на каждый коммит» — известно и отклонено по этой же причине. ## Механизировано Проверяется командами из [CLAUDE.md](../CLAUDE.md); прозой не дублируется и в промптах ревью не пересказывается. +### Ошибки и отказы + | Правило | Где механизировано | | --- | --- | | Сравнение ошибок через `errors.Is` и `errors.As`, не `==` и не приведением типа | `.golangci.yml` → `errorlint` | -| Непроверенное возвращаемое значение ошибки | `.golangci.yml` → `errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close` и `send` | +| Ошибка не узнаётся сравнением текста сообщения (`strings.Contains(err.Error(), …)`, `err.Error() == …`) | `internal/archrules` → `TestОшибкаНеУзнаётсяПоТексту` | +| Непроверенное возвращаемое значение ошибки | `.golangci.yml` → `errcheck`, включая присваивание в `_` (`check-blank`). Отказ, который решено не проверять, объявляют в `exclude-functions` поимённо — там сегодня `defer Close`, `os.Remove` и `send` | +| Ошибки — только stdlib, без сторонних пакетов | `.golangci.yml` → `depguard` | + +### Структура и границы + +| Правило | Где механизировано | +| --- | --- | +| Ядро (`internal/service`) не знает ни адаптеров, ни транспортов | `internal/archrules` → `TestЯдроНеЗнаетОбАдаптерах`, `TestЯдроНеЗнаетОТранспортах` | +| Транспорты (`controller/http`, `controller/tg`, `controller/worker`) не знают друг о друге | `internal/archrules` → `TestТранспортыНеЗнаютДругОДруге` | +| Адаптер не знает ни ядра, ни транспортов | `internal/archrules` → `TestАдаптерыНеЗнаютНиЯдра_НиТранспортов` | +| Колонки очереди согласованы: перечень захвата ↔ структура захвата ↔ шаг схемы ↔ запись коллекции ↔ перенос поля в задачу | `internal/archrules` → четыре правила о захвате. Закрывает инвариант «колонки правятся в четырёх местах» (CLAUDE.md, major), которого компилятор не держит. Литерал колонки ищется в телах нужных функций: по файлу целиком условие выполнялось бы тегами `db:"…"` самой структуры, и правило было бы зелёным всегда | + +### Время, вывод, конфигурация + +| Правило | Где механизировано | +| --- | --- | +| Время читают `clock.Now` (метка, UTC) и `clock.Start` (длительность, монотонные часы) — не `time.Now` по месту | `.golangci.yml` → `forbidigo`; единая точка — `internal/clock` | +| Вывод идёт через `slog`, а не `fmt.Print*` и не встроенными `print`/`println` | `.golangci.yml` → `forbidigo`. Не ловит `fmt.Fprintln(os.Stdout, …)` — первый аргумент по имени функции не судится; остаток прозой в [conventions/logging.md](conventions/logging.md) | +| Конфигурация приезжает из TOML, а не из окружения | `.golangci.yml` → `forbidigo`: `os.Getenv`, `os.LookupEnv`, `os.Environ`, `os.ExpandEnv` — все четыре, иначе запрет обходится соседним именем | +| Форма вызова `slog`: только пары «ключ-значение», атрибуты (`slog.String` и прочие) не употребляются вовсе; `msg` — константа | `.golangci.yml` → `sloglint` (`kv-only` запрещает атрибуты целиком, а не только смешение) | + +### Проверки о самих проверках + +| Правило | Где механизировано | +| --- | --- | | Проверка судит ответ по готовому ответу (`Result()`), а не по живой карте заголовков обработчика | `.golangci.yml` → `forbidigo` с `analyze-types`, находки только в `*_test.go`. Судит по типу приёмника (`httptest.ResponseRecorder`), поэтому ловит любую форму: цепочкой, через переменную, по индексу карты, обходом, полем `HeaderMap`. Остаётся ревью проверка, идущая мимо recorder — через свой `http.ResponseWriter` | -| Форматирование исходников | `.golangci.yml` → `gofmt` | +| Каждый сценарий нормы шага сверки версий проверен мутацией, а не памятью | `scripts/check_go_version_test.go` — 20 сценариев спеки `toolchain` плюс два свойства самого шага: исход не зависит от установленного `go`, и шаг не зовёт ни `go`, ни `docker`, ни сеть | + +### Форма кода и файлов вне Go + +| Правило | Где механизировано | +| --- | --- | +| Форматирование исходников | `.golangci.yml` → `gofmt`; на pre-commit правится на месте | | Подозрительные конструкции языка | `.golangci.yml` → `govet`, `staticcheck`, `ineffassign`, `unused` | +| Опечатка в комментарии и в тексте ошибки | `.golangci.yml` → `misspell` | +| Скрипты оболочки | `Taskfile.yml` → шаг `shell` (`shellcheck`), он же на pre-commit | +| Форма `Dockerfile` | `Taskfile.yml` → шаг `dockerfile` (`hadolint`), он же на pre-commit | +| Одно число версии Go в `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md` | `Taskfile.yml` → шаг `go-version` (`scripts/check-go-version.sh`) | + +### Хранилище, документы, секреты, зависимости + +| Правило | Где механизировано | +| --- | --- | +| Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` в `docs/.docs.json` | +| Согласованность каталога задач, форма `openspec/config.yaml` | `tasks.py check`, `openspec.py check` | | Секреты в коммите | `lefthook.yml` → `gitleaks git --staged` | | Достижимая из кода уязвимость в зависимостях | `Taskfile.yml` → шаг `vulns` (`govulncheck ./...`) | -| Раскладка документов, битые ссылки, изменённый шаг схемы без правки `database.md` | `docs.py check`; каталог шагов задаёт ключ `migrations` в `docs/.docs.json` | -| Одно число версии Go в `go.mod`, `Dockerfile`, `CLAUDE.md` и `README.md` | `Taskfile.yml` → шаг `go-version` (`scripts/check-go-version.sh`) | Не названное здесь место механизации означает, что проход по конвенциям будет добросовестно проверять уже проверенное. +## Подавления: что и почему + +Подавление — это решение, а не настройка, поэтому каждое названо поимённо и с +причиной. Причина живёт строкой рядом с подавлением (в `.golangci.yml` или +`Taskfile.yml`), а здесь — их перечень, чтобы видеть все разом. + +| Подавлено | Где | Почему | +| --- | --- | --- | +| `errcheck` на `defer Close`, `os.Remove` и `send` | `.golangci.yml`, `exclude-functions` | Отказ, который решено не проверять, объявляют поимённо — так он заметен | +| Правило о заголовках вне `*_test.go` | `.golangci.yml`, `exclusions` | В рабочем коде `Header()` и есть способ отдать заголовок | +| `time.Now` внутри `internal/clock` | там же | Единой точке чтения времени нечем читать время иначе | +| Чтение времени и окружения в `*_test.go` | там же | Проверка строит вход прогона — фикстуру времени, `PATH`, окружение дочернего процесса, — а не метку домена и не настройки приложения. Исключение объявлено по тексту сообщения: правило называет четыре имени, и исключение обязано покрывать те же четыре | +| `SC1007` в `scripts/check-go-version.sh` | директива в скрипте | Ложное срабатывание на идиому `CDPATH= cd`, которая защищает `cd` от чужого `CDPATH` | +| `DL3007` (`alpine:latest`) | `Taskfile.yml`, шаг `dockerfile` | Открытая задача `pin-runtime-image-base`; до её решения шаг краснел бы на известном | +| `DL3018` (закрепить версии `apk`) | там же | Alpine не держит старые версии пакетов в репозитории: закрепление ломает сборку через недели | + ## Что остаётся прозой -**Из перечисленного в записях конвенций правилом выражено одно** — сравнение -ошибок через `errors.Is` и `errors.As` (`errorlint`, строка таблицы выше). Прозой -остаётся всё прочее: ни константный `msg` лога (`sloglint`), ни запрет -`fmt.Print*` и `os.Getenv` (`forbidigo` заведён, но правило у него одно — о том, -чем судят ответ в проверках; этих двух запретов в нём нет), ни запрет сторонних -пакетов ошибок (`depguard`), ни архитектурные тесты-сканеры. Это следующий шаг -переноса в правило: свойство, оставшееся прозой, проверяет человек на каждом -ревью заново. +**Из перечисленного в записях конвенций правилом выражено не всё.** Прозой +остаётся то, чему нет ни готового правила, ни детерминированного оракула: +уровень лога по адресату, единая логирующая точка на доменной границе, словарь +имён полей, канонический вид идентификатора, естественные ключи у деталей. +Свойство, оставшееся прозой, проверяет человек на каждом ревью заново — это и +есть первая ступень лестницы, и подъём с неё всегда выигрыш. + +Названы поимённо и **остатки правил** — то, что правило не ловит и потому +осталось человеку: + +- вывод в stdout через `fmt.Fprintln(os.Stdout, …)` и `os.Stdout.WriteString`: + `forbidigo` судит по имени вызванной функции, а не по её первому аргументу; +- проверка, судящая ответ мимо recorder — через свой `http.ResponseWriter`; +- направление «транспорт не знает адаптера»: сегодня оно нарушено осознанно — + `controller/http` импортирует адаптер хранилища, потому что HTTP-поверхность и + есть роутер этого хранилища. Изъятие названо в + [architecture.md](architecture.md), «Принципы», и правила на это направление + нет. + +Отдельно названы **правила, чей подъём отклонён**: + +- `key-naming-case` у `sloglint` — словарь полей намеренно смешанный: доменные + поля `snake_case`, системные домены с точкой (`http.method`, `ext.service`); +- `msg-style: lowercased` у `sloglint` — это ровно конвенция «`msg` — короткая + константа в нижнем регистре», но код называет сообщения предложениями с + заглавной, и это объявленное *Расхождение*. Цена подъёма — переписать больше + ста вызовов, и она не заплачена; +- закрепление версий пакетов `apk` (`DL3018`) — см. подавления выше. + +**Кандидат, ждущий решения:** `clock.Now` и `clock.Start` отдают один тип, поэтому +`time.Since(clock.Now())` компилируется и молча меряет длительность настенными +часами — ровно то, против чего пакет и написан. Держал бы это компилятор, будь у +`Start` свой тип с методом `Elapsed()`. Сегодня таких мест нет. + +## Как заводят новое правило + +Порядок один и тот же, и последние два шага пропускать нельзя. + +1. **Найти дом.** Ступень лестницы выбирается по тому, чем свойство + выражается, а не по тому, что проще включить. +2. **Написать причину рядом.** Правило без причины снимают при первом же + неудобстве: тот, кто снимает, не знает, что оно ловило. +3. **Починить находки, а не подавить.** Подавление годится, когда правило + говорит не о том, что мы имели в виду; тогда оно попадает в перечень выше с + причиной. Подавление «пока некогда» — это отложенная работа, и её место в + каталоге задач, а не в конфиге. +4. **Проверить мутацией.** Внести ровно то нарушение, против которого правило + написано, и убедиться, что проверка краснеет и называет место. Правило, + принятое молчанием инструмента, — это не правило: прецеденты есть, и записаны + они в [review.md](review.md) (журнал 2026-08-11 про недостижимую норму, + 2026-08-13 про обходимый текстовый запрет). +5. **Записать строкой здесь** и удалить прозу из конвенции, если правило её + заменило. diff --git a/docs/conventions/README.md b/docs/conventions/README.md index 63fdbf1..4dcff65 100644 --- a/docs/conventions/README.md +++ b/docs/conventions/README.md @@ -18,12 +18,13 @@ severity — в [CLAUDE.md](../../CLAUDE.md). Четыре записи перенесены из проекта jellybit — тот же Go, тот же автор, те же задачи. Код transcriber написан раньше и **части правил не следует**: ключи — -UUID вместо ULID, время берётся `time.Now()` по месту, лог пишется на каждом -шаге и дублируется воркером. +UUID вместо ULID, лог пишется на каждом шаге и дублируется воркером, `msg` — +предложение с заглавной буквы вместо константной категории. -Из этого перечня одно уже закрыто: доменные ошибки проверялись приведением типа -до 2026-08-11, задача `errors-as-instead-of-typecast`. Приведение типа на этом -месте больше не долг, а регрессия. +Из этого перечня закрыты два. Доменные ошибки проверялись приведением типа до +2026-08-11, задача `errors-as-instead-of-typecast`. Время брали `time.Now()` по +месту до 2026-08-13 — теперь его читает единая точка `internal/clock`, и правило +держит линтер. Оба места больше не долг, а регрессия. Пятая, `web-ui.md`, тоже пришла оттуда, но не прижилась: jellybit работает на htmx, а здесь решено делать SPA — и перенесённый текст снят целиком. @@ -52,8 +53,9 @@ htmx, а здесь решено делать SPA — и перенесённы Перечень правил, доведённых до проверки, и место настройки каждого — в [../autotests.md](../autotests.md). Там же сказано, что из перечисленного в -записях осталось прозой и потому проверяется человеком на каждом ревью заново: -сегодня правилом выражено ровно одно свойство из всех записей. +записях осталось прозой и потому проверяется человеком на каждом ревью заново, и +там же названы остатки правил — то, что правило не ловит. Числа механизированного +здесь нет намеренно: оно протухает при каждом новом правиле. Здесь этот перечень не повторяется. Дом у него один, и он не тут: конвенции говорят, как писать код, а не какими инструментами его читают. diff --git a/docs/conventions/config.md b/docs/conventions/config.md index 338d9dd..3e724e5 100644 --- a/docs/conventions/config.md +++ b/docs/conventions/config.md @@ -8,9 +8,10 @@ комментариями снабжена половина полей; валидации на старте нет вовсе, кроме проверки пустых ключей внутри адаптеров. -**Механизировано:** ничего. Запрет `os.Getenv` для конфигурации правилом линтера -не выражен, и `godotenv` в `main.go` загружает `.env` — то есть окружение сейчас -участвует. +**Механизировано:** запрет `os.Getenv` — `forbidigo` в `.golangci.yml` +([../autotests.md](../autotests.md), «Механизировано»). Он держит правило «настройки +приезжают из TOML»; `godotenv` в `main.go` по-прежнему загружает `.env`, но кладёт +его в окружение процесса, а не в настройки приложения. ## Принципы diff --git a/docs/conventions/database.md b/docs/conventions/database.md index ecd097c..32f7ec2 100644 --- a/docs/conventions/database.md +++ b/docs/conventions/database.md @@ -2,16 +2,17 @@ Как мы устраиваем таблицы и ключи. Актуальная схема — [../database.md](../database.md). -**Взято из проекта jellybit целиком.** Сегодняшний код transcriber этому не -следует ни в одном пункте: ключи — UUID v4, а не ULID; время — `time.Now()` по -месту вызова в локальной зоне, а не единой точкой в UTC; единой точки генерации -и разбора нет. Правила действуют на новый код; переписывание существующего — +**Взято из проекта jellybit целиком.** Сегодняшний код transcriber следует +этому частью: ключи — UUID v4, а не ULID, и единой точки их генерации нет. Время +единой точкой читается с 2026-08-13 — `internal/clock`, метка в UTC, — и правило +держит линтер. Правила действуют на новый код; переписывание существующего — отдельная работа, и до неё расхождение читается как долг, а не как нарушение. -**Механизировано:** одно — сверка изменённого шага схемы с -[../database.md](../database.md), шаг гейта `docs.py check` -([../autotests.md](../autotests.md), «Механизировано»). Под прочие пункты ни правила -линтера, ни теста-сканера в transcriber нет. +**Механизировано:** сверка изменённого шага схемы с +[../database.md](../database.md) (`docs.py check`), чтение времени единой точкой +(`forbidigo` плюс `internal/clock`) и согласованность колонок очереди +(тест-сканер `internal/archrules`). Прочие пункты — прозой; адреса — +[../autotests.md](../autotests.md), «Механизировано». ## Первичные ключи — ULID, не автоинкремент diff --git a/docs/conventions/errors.md b/docs/conventions/errors.md index e821e4f..c00c318 100644 --- a/docs/conventions/errors.md +++ b/docs/conventions/errors.md @@ -9,9 +9,10 @@ Главное: единой точки отображения доменной ошибки в ответ нет, обработчики решают сами. -**Механизировано:** приведение типа и `err == ErrX` ловит `errorlint` в -`.golangci.yml`. Запрета сторонних пакетов ошибок (`depguard`) нет — сторонних -пакетов ошибок в проекте и так нет. +**Механизировано:** приведение типа и `err == ErrX` ловит `errorlint`, +сторонние пакеты ошибок — `depguard`, узнавание ошибки по тексту сообщения — +тест-сканер `internal/archrules`. Перечень и адреса — +[../autotests.md](../autotests.md), «Механизировано». ## Базовая идиома: stdlib diff --git a/docs/conventions/logging.md b/docs/conventions/logging.md index b820ae8..0de411c 100644 --- a/docs/conventions/logging.md +++ b/docs/conventions/logging.md @@ -11,10 +11,13 @@ OpenSpec. категория; шаг конвейера логирует и себя, и свой исход, и при этом возвращает ошибку выше, где её логируют снова. -**Механизировано:** ничего из перечисленного ниже. `forbidigo` в `.golangci.yml` -включён, но правило у него одно и о другом — чем судят ответ в проверках -([../autotests.md](../autotests.md), «Механизировано»); `sloglint` не заведён, и ни один -пункт этой записи правилом не выражен. +**Механизировано:** форма вызова — `sloglint`: только пары +«ключ-значение», `msg` константой, **атрибуты (`slog.String` и прочие) не +употребляются вовсе**. Запрет `fmt.Print*` и встроенных `print`/`println` — +`forbidigo`; вывод в stdout через `fmt.Fprintln(os.Stdout, …)` правилом не +ловится и остаётся прозой этой записи. Прозой остаются также уровень по адресату, +единая логирующая точка и словарь имён полей: оракула у них нет. Адреса — +[../autotests.md](../autotests.md), «Механизировано». ## Принципы diff --git a/docs/review.md b/docs/review.md index a3be27f..9fd512d 100644 --- a/docs/review.md +++ b/docs/review.md @@ -238,6 +238,47 @@ API и имя не откатываются обратной правкой по поймать их было некому. У восстановленных нет поля «Чем воспроизведён», и выдумывать его задним числом нельзя. +## 2026-08-13 — конец потока распознавания узнавался по тексту сообщения [пойман сканером] + +- **Где:** `internal/adapter/recognizer/yandex/speechkit.go`, чтение потока + результата распознавания +- **Симптом:** сегодня не наблюдался — путь рабочий, пока библиотека отдаёт конец + потока значением `io.EOF`. Отказ с текстом «EOF» был бы принят за конец потока, + и расшифровка вернулась бы усечённой: пользователь получил бы половину записи + как готовый результат +- **Причина:** конец потока узнавался сравнением `err.Error() == "EOF"`. Текст + сообщения — не признак: его носит и чужая ошибка, а сменит его библиотека — + условие перестанет срабатывать вовсе, и оба исхода молчаливы +- **Чем воспроизведён:** не воспроизводился на живом сервисе — прогон на реальных + ключах запрещён. Найден тестом-сканером `internal/archrules` при его заведении +- **Почему не поймали раньше:** `errorlint` видит `err == ErrX` и приведение типа, + но матчинг по тексту не видит; прозой это правило записано не было, и ревью его + не спрашивало +- **Что меняем:** узнавание переведено на `errors.Is(err, io.EOF)`; класс закрыт + тестом-сканером (docs/autotests.md, «Ошибки и отказы») + +## 2026-08-13 — правило гейта обходилось одной лишней строкой [пойман ревью] + +- **Где:** `.golangci.yml`, правило `forbidigo` о суждении по живой карте + заголовков — заведено в тот же день задачей `response-assertions-judge-result` +- **Симптом:** правило ловило только прямую цепочку `w.Header().Get`. Присваивание + в переменную (`h := w.Header()`), чтение по индексу карты, обход `range` и поле + `HeaderMap` проходили гейт зелёными — то есть класс, стоивший трёх зелёных + гейтов, возвращался четвёртый раз, и уже без человеческой страховки: документы + успели снять его с прохода ревью +- **Причина:** `forbidigo` по умолчанию судит по печатному тексту вызова, а не по + типу значения. Правило, записанное текстом, отсекает одну форму записи, а не + свойство +- **Чем воспроизведён:** прогоном линтера на файле проверок с шестью формами + чтения живой карты: помечена была одна +- **Почему не поймали раньше:** правило проверили ровно тем нарушением, против + которого писали. Мутация была, но одна — нужна была по одной на каждую форму +- **Что меняем:** правило судит по типу приёмника (`analyze-types`, + `httptest.ResponseRecorder.Header` и `.HeaderMap`) и ловит все шесть форм; + проверено мутацией по каждой. Отсюда же строка в docs/autotests.md, «Лестница + механизации»: запрет по имени, обходимый лишней строкой, — это ступень + тест-сканера, наряженная запретом + ## 2026-08-12 — закрыли поверхность так, что войти не мог никто [пойман ревью] - **Где:** шаг схемы `202608120001` задачи `oidc-login`, правило создания записи