httpapi: форма провода читающих маршрутов объявлена транспортом
- доменные типы internal/catalog лишились json-тегов и MarshalJSON; типы metricWire/layerWire/aggregationWire и перевод catalogWire живут в транспорте, тело отказа тоже получило объявленный тип — байты ответа не изменились - заведён сторож: обход графа типов ответа утверждает, что домен не доезжает до сериализации, плюс требование json-тега на полях транспортных структур и заведомо красные случаи к обоим правилам - решение с ценой обеих сторон записано в architecture.md и ADR; шаг lint в гейте получил свой кеш — общий на машину красил прогон находками из чужого worktree
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
# Форма провода принадлежит транспорту, а не домену
|
||||
|
||||
- **Дата:** 2026-08-04
|
||||
- **Источник:** openspec/changes/archive/2026-08-04-forma-provoda-chteniya/design.md
|
||||
|
||||
## Решение
|
||||
|
||||
Публичный контракт читающих маршрутов объявляет транспорт: `internal/httpapi`
|
||||
держит собственные типы с `json`-тегами и переводит в них доменное значение
|
||||
присваиванием поле в поле. Доменные типы (`internal/catalog` и далее) тегов не
|
||||
несут и до сериализации не доезжают. То же правило покрывает тело отказа; MCP
|
||||
собственной формы не объявляет.
|
||||
|
||||
Противоположное решение — **доменные типы объявлены формой провода намеренно** —
|
||||
рассмотрено первым как живая и уважаемая практика и отвергнуто по названной
|
||||
причине.
|
||||
|
||||
## Почему
|
||||
|
||||
Каталог до этого изменения жил вторым способом: `internal/catalog` сам нёс
|
||||
`json`-теги и `Style.MarshalJSON`, а транспорт владел только оболочкой
|
||||
`{"metrics": …}`. Отсюда три пути смены **публичного** контракта, ни один из
|
||||
которых не касается транспорта и все три выглядят как внутренняя правка:
|
||||
переименование поля; разъединение встроенного `Basis` (плоскость объекта
|
||||
`aggregation` была следствием встраивания); появление внутреннего поля.
|
||||
Удерживал контракт один литерал в тесте, и о том, что этот литерал и есть
|
||||
контракт, не было сказано нигде.
|
||||
|
||||
Решение принималось до того, как образец скопируют четыре маршрута и MCP —
|
||||
потом это была бы не развилка, а археология.
|
||||
|
||||
Литература расколота, и обе стороны названы в источнике поимённо: домен = провод
|
||||
у Ben Johnson (`benbjohnson/wtf` — доменные типы корневого пакета несут теги
|
||||
напрямую) и у Prometheus (`web/api/v1` — конверт свой, полезная нагрузка
|
||||
доменная); раздельно у Gitea (`modules/structs` против `models`), Docker
|
||||
(`api/types`), go-kit (service → endpoint → transport) и Kubernetes (internal
|
||||
против версионированных `k8s.io/api` плюс кодогенерируемая конверсия).
|
||||
Ортогональный совет Mat Ryer — объявлять типы ответа рядом с их обработчиком —
|
||||
взят вместе с названной им ценой.
|
||||
|
||||
Развилку решил **факт проекта, а не вкус**. Цитата из источника:
|
||||
|
||||
> Правило «доменный тип и есть форма провода» ломается на втором же маршруте
|
||||
> цели. Провод точек обещан как `{ts, tz_offset, units, values}`
|
||||
> (`docs/architecture.md`, раздел «Форма ответа»), а `store.Point` несёт
|
||||
> `{Start, End, OffsetSeconds, Raw}` — эти два набора не совпадают **ни одним
|
||||
> именем**. Доменный тип формой провода там быть не может даже при желании.
|
||||
|
||||
Второй факт — внутренний прецедент, и он в ту же сторону:
|
||||
|
||||
> Хранилище уже применяет ровно предлагаемое решение. `store.Point` не несёт
|
||||
> `json`-тегов вовсе; формат сжатого `payload` объявлен **отдельным
|
||||
> неэкспортированным** типом `storedPoint`, а `encodePayload` переводит одно в
|
||||
> другое **полем в поле**.
|
||||
|
||||
Плюс `internal/httpapi/ingest.go`, который своим типом ответа владел с самого
|
||||
начала. То есть решение **устраняет** второй способ, а не заводит его: каталог
|
||||
был отклонением от уже принятого в проекте образца.
|
||||
|
||||
Отдельная развилка того же изменения — **чем контракт сторожится**, и там тоже
|
||||
есть поимённый отказ:
|
||||
|
||||
> `golang.org/x/exp/apidiff` и `go-apidiff` отвергнуты, и причина измерима: они
|
||||
> сравнивают **Go-API** на предмет компилируемости клиентского кода. Смена
|
||||
> строки тега (`json:"metric"` → `json:"name"`) при неизменном Go-имени поля для
|
||||
> них — не изменение вовсе. То есть ровно тот класс, ради которого заводится
|
||||
> сторож, они не видят.
|
||||
|
||||
Генерация OpenAPI из кода (`swaggo`) отвергнута как сторож по другой причине —
|
||||
она фотографирует уже случившееся, — но не как способ **опубликовать** контракт:
|
||||
владелец решил в этом же спринте, что источником истины будет рукописная
|
||||
OpenAPI-спека. Байтовое утверждение поэтому названо **детектором изменения**, а
|
||||
не контрактом.
|
||||
|
||||
## Последствия
|
||||
|
||||
- `+` Публичный контракт чтения перестал быть побочным эффектом имён полей
|
||||
домена. Переименование поля домена ломает компиляцию перевода — разработчику
|
||||
говорят в момент правки; байты ответа при этом те же (проверено: сборка
|
||||
базовой ревизии и сборка ветки против одного файла базы дали побайтово
|
||||
идентичные 2268 байт).
|
||||
- `+` Появился машинный сторож: обход графа типов ответа утверждает, что ни один
|
||||
тип домена не достигает сериализации, а требование `json`-тега на каждом
|
||||
экспортированном поле транспортной структуры закрывает калитку
|
||||
`type pointWire store.Point`. Рядом — заведомо красный случай на 13 позиций,
|
||||
потому что проверка, доказывающая отсутствие, зелена и будучи сломанной.
|
||||
- `+` Плоскость объекта `aggregation` перестала быть следствием встраивания
|
||||
`Basis` в домене и стала записанным решением транспорта.
|
||||
- `−` **Цена обратная, и она взята сознательно:** новое поле домена в ответ само
|
||||
не попадёт — его обязан перечислить перевод. Поле, не доехавшее до клиента, —
|
||||
такой же дефект, как поле, уехавшее случайно, просто другой.
|
||||
- `−` Форма объявлена дважды: типы плюс перевод на каждый маршрут.
|
||||
- `−` Словарь рода остался в домене (`Style.String()`), и провод зовёт его же.
|
||||
Правка `String()` ради читаемости лога изменит тело ответа клиенту. Из двух
|
||||
цен взята эта: свой `switch` на проводе сторожил бы лучше, но завёл бы второй
|
||||
словарь, который разошёлся бы с первым молча.
|
||||
- `−` Сторож остаётся **opt-in**: маршрут, забывший строку в таблице образцов,
|
||||
останется без него молча. Развилка вынесена владельцу (см. ниже).
|
||||
- `−` Обход слеп к типам, достижимым только через `any`/интерфейс, и к типам
|
||||
внешних зависимостей. Слепота названа в источнике и воспроизведена замером,
|
||||
а не предположена.
|
||||
|
||||
## Открыто, решает владелец
|
||||
|
||||
Записано здесь, а не в файле задачи: файл закрытой задачи удаляется.
|
||||
|
||||
**Проверять ли полноту таблицы образцов машиной.** Сторож покрывает три типа,
|
||||
идущие через `writeJSON` сегодня; впереди четыре маршрута и MCP — четыре шанса
|
||||
забыть строку, и забытая строка не отличима от отсутствия проблемы.
|
||||
|
||||
- **(а)** обход роутера (`chi.Walk`) с утверждением, что число читающих
|
||||
маршрутов равно числу строк таблицы. Около 15 строк, забывание краснеет; цена
|
||||
— сцепка теста с роутером. **Рекомендация:** это ровно тот класс «проверка
|
||||
отсутствия зелена и будучи сломанной», против которого это же изменение завело
|
||||
конвенцию заведомо красного случая, — а на полноту таблицы конвенция не
|
||||
распространена.
|
||||
- **(б)** тестовый hook в `writeJSON`, собирающий типы реально закодированных
|
||||
ответов. Ноль мест на новый маршрут, но шов в продакшн-коде.
|
||||
- **(в)** оставить на спеке `read-api` и комментарии-образце. Ноль строк сейчас,
|
||||
одна молчащая дыра на каждый забытый маршрут.
|
||||
|
||||
**Что в проекте считается спекой — контракт системы или ещё и дисциплина его
|
||||
смены.** Здесь развилка разрешена в сторону «спека нормирует наблюдаемое,
|
||||
дисциплина живёт в конвенциях»: этот выбор дешевле откатить, и у второго
|
||||
варианта нет предмета для сверки «спека → код». Прецедент задан на четыре
|
||||
следующие задачи цели — если владелец решит иначе, переносить придётся их все.
|
||||
@@ -33,6 +33,11 @@
|
||||
| Дата | Запись | Статус |
|
||||
| --- | --- | --- |
|
||||
|
||||
- [ADR-2026-08-04-forma-provoda-prinadlezhit-transportu](ADR-2026-08-04-forma-provoda-prinadlezhit-transportu.md)
|
||||
— публичный контракт чтения объявляет транспорт, а не домен; «доменные типы и
|
||||
есть форма провода» (`wtf`, Prometheus) отвергнуто фактом — поля `store.Point`
|
||||
не совпадают с обещанным проводом точек ни одним именем; `apidiff` как сторож
|
||||
отвергнут: смены `json`-тега он не видит вовсе.
|
||||
- [ADR-2026-08-04-novizna-sekcii-vyvoditsya-iz-zhurnala](ADR-2026-08-04-novizna-sekcii-vyvoditsya-iz-zhurnala.md)
|
||||
— признак «секция встречена впервые» выводится запросом к журналу; реестр по
|
||||
образцу `category_value` отвергнут как вторая копия факта, с названным
|
||||
|
||||
+46
-1
@@ -230,7 +230,7 @@ capability**, и здесь стоит ссылка, а не пересказ т
|
||||
| `replay` | проигрывание журнала в витрину: состав, порядок, отчёт | [`reindex`](../openspec/specs/reindex/spec.md) |
|
||||
| `catalog` | каталог разрезов и измерение рода агрегации | [`catalog`](../openspec/specs/catalog/spec.md) |
|
||||
| `store` | SQLite: доставки, часовые объекты, тренировки, записи | [`storage`](../openspec/specs/storage/spec.md) |
|
||||
| `httpapi` | приём и read API | [`ingest`](../openspec/specs/ingest/spec.md), [`catalog`](../openspec/specs/catalog/spec.md) |
|
||||
| `httpapi` | приём, read API и **форма провода** ответов чтения | [`ingest`](../openspec/specs/ingest/spec.md), [`catalog`](../openspec/specs/catalog/spec.md), [`read-api`](../openspec/specs/read-api/spec.md) |
|
||||
|
||||
## Приём
|
||||
|
||||
@@ -1594,6 +1594,51 @@ GET /healthz
|
||||
семантику разбирает клиент по имени метрики. Полная нормализация означала бы,
|
||||
что каждая новая метрика требует правки коллектора, а незнакомая теряется.
|
||||
|
||||
### Форма провода
|
||||
|
||||
**Форму ответа объявляет транспорт, а не домен.** Каждый читающий маршрут
|
||||
`internal/httpapi` держит собственные типы с `json`-тегами и переводит в них
|
||||
доменное значение присваиванием поле в поле; доменные типы (`internal/catalog`
|
||||
и далее) `json`-тегов не несут и до сериализации не доезжают. То же правило
|
||||
покрывает тело отказа. MCP собственной формы не объявляет — адаптер переводит
|
||||
вызовы в те же обработчики.
|
||||
|
||||
Цена названа с обеих сторон, потому что она обратная, а не односторонняя.
|
||||
|
||||
- **Домен = провод** (как было у каталога): формы объявлены один раз, перевода
|
||||
нет, ноль строк на маршрут. Платим тем, что публичный контракт меняется
|
||||
правкой домена **молча** — переименованием поля, разъединением встроенной
|
||||
структуры (плоскость `aggregation` была следствием встраивания `Basis`),
|
||||
появлением внутреннего поля. Ни одна из трёх правок транспорт не трогает.
|
||||
- **Раздельно** (взято): контракт меняется только правкой транспорта, то есть
|
||||
действием. Платим двумя вещами. Форма объявлена дважды — типы и перевод на
|
||||
каждый маршрут. И цена **обратная**: новое поле домена в ответ само не
|
||||
попадёт, его обязан перечислить перевод; поле, не доехавшее до клиента, —
|
||||
такой же дефект, как поле, уехавшее случайно, просто другой.
|
||||
|
||||
Развилку решил факт, а не вкус: провод точек обещан как
|
||||
`{ts, tz_offset, units, values}`, а `store.Point` несёт
|
||||
`{Start, End, OffsetSeconds, Raw}` — эти наборы не совпадают ни одним именем,
|
||||
и доменный тип формой провода там быть не может. Хранилище, кстати, уже живёт
|
||||
по этому правилу: формат `payload` объявлен отдельным неэкспортированным
|
||||
`storedPoint`, а `encodePayload` переводит в него полем в поле.
|
||||
|
||||
Сторожей два, и роли у них разные. **Обход графа типов ответа** (внутренний
|
||||
тест `httpapi`) утверждает, что домен до энкодера не доезжает — отсюда и
|
||||
следует, что переименование поля домена байт не меняет; рядом стоит заведомо
|
||||
красный случай, потому что проверка, доказывающая отсутствие, зелена и будучи
|
||||
сломанной. **Байтовый литерал** на каждую различимую форму ответа — детектор
|
||||
изменения формы: он краснеет в момент правки. Источником истины контракта он
|
||||
не является — им станет рукописная OpenAPI-спека, и сверку с маршрутами внесёт
|
||||
в гейт отдельная задача.
|
||||
|
||||
Разбор чужих решений (домен = провод у `wtf` и Prometheus; раздельно у Gitea,
|
||||
Docker и go-kit; версионирование с конверсией у Kubernetes; отвергнутый
|
||||
`apidiff`, который смены `json`-тега не видит вовсе) —
|
||||
[design.md изменения](../openspec/changes/archive/2026-08-04-forma-provoda-chteniya/design.md).
|
||||
Ссылка markdown-ссылкой намеренно: инлайн-код `docs.py check` не проверяет, а
|
||||
путь угадывался до архивации.
|
||||
|
||||
### MCP
|
||||
|
||||
Поверх Read API — адаптер MCP, чтобы агент подключался без промежуточного
|
||||
|
||||
@@ -67,6 +67,26 @@
|
||||
экспортированные функции, обходит лишь записи, достижимые из словаря: с
|
||||
неплоской таблицей она остаётся зелёной (воспроизведено). Такие утверждения
|
||||
живут во внутреннем тесте пакета и перебирают саму структуру.
|
||||
- **Публичная форма ответа закрепляется байтами целого тела, и каждая различимая
|
||||
форма — своим литералом.** Разбор проглатывает молча ровно то, что клиент
|
||||
видит первым: `nil`-срез уезжает как `null`, отсутствующий ключ неотличим от
|
||||
ключа с нулём, а разыменованный `*time.Time` даёт правдоподобную дату
|
||||
`0001-01-01` вместо `null`. Тест, сличающий разобранные структуры или
|
||||
подстроки, зелен в каждом из этих случаев — проверка «в ответе есть
|
||||
`"first_hour"`» проходит и на нулевой дате. Различимых форм у ответа обычно
|
||||
больше одной (пустая коллекция, измеренное значение, неизмеренное), и литерал
|
||||
нужен каждой: одна закреплённая форма оставляет остальные без сторожа именно
|
||||
там, где ручной перевод и ошибается. Литерал при этом **детектор изменения**,
|
||||
а не источник истины контракта — правишь литерал, значит правишь контракт, и
|
||||
рядом обязана лежать правка спеки.
|
||||
- **Проверка, доказывающая ОТСУТСТВИЕ, несёт рядом заведомо красный случай.**
|
||||
«Доменного типа в графе ответа нет», «значения точки в логе нет», «записи в
|
||||
таблице нет» — все они зелены и будучи сломанными: протухшая константа,
|
||||
пропущенная позиция обхода, перепутанное сравнение выглядят снаружи как
|
||||
«искомого нет». Это обобщение двух правил ниже (отрицательный контроль для
|
||||
правил порядка; утверждение о таблице-константе обходит саму таблицу): у
|
||||
проверки на отсутствие обязан быть предъявленный вход, на котором она
|
||||
краснеет.
|
||||
- **Тест «в логе нет значения» проверяет запись без служебных полей, а не сырой
|
||||
буфер.** Метка времени содержит доли секунды, поэтому искомая подстрока
|
||||
находится в ней сама: проверка на «5.1» краснела примерно раз на сотню
|
||||
|
||||
@@ -552,3 +552,41 @@
|
||||
описывать тот случай, ради которого снято» действует только для тестов
|
||||
(`conventions/testing.md`). На `design.md` оно теперь распространено записью
|
||||
ниже, но механизировать его нечем.
|
||||
|
||||
## 2026-08-04 — гейт дважды покраснел от чужого мусора: кеш линтера и черновик в `./tmp` [пойман]
|
||||
|
||||
- **Где:** конвейер, а не код — `scripts/gate.py`, шаги `lint` и `test`
|
||||
- **Симптом:** в задаче про форму провода `task gate` дал `FAIL lint` с
|
||||
сообщением `../../internal/store/store.go:260: use of time.Now forbidden` —
|
||||
путь ведёт в **главный репозиторий**, а прогон шёл в worktree задачи. Позже, в
|
||||
том же прогоне задачи, `FAIL test` на
|
||||
`TestОднаМеткаИзТелаУбиваетМаршрутКаталога` — тесте, которого в задаче нет
|
||||
вовсе.
|
||||
- **Причина:** два разных механизма, один класс — в гейт затекает то, что к
|
||||
изменению отношения не имеет.
|
||||
- `golangci-lint` ходит в **общий на машину** `~/.cache/golangci-lint`, а
|
||||
конвейер задач работает в нескольких worktree одного модуля (`tmp/wt-*`).
|
||||
Кеш отдаёт замечания, привязанные к путям чужого дерева, и правило-исключение
|
||||
`^internal/(ident|store)/` на путь вида `../…` не распространяется.
|
||||
- `go test ./...` не знает про `./tmp`: `.golangci.yml` каталог исключает
|
||||
(коммит `9f77e56`), а `go test` — нет. Проход `adversary` оставил там свой
|
||||
падающий тест-оракул, и он стал частью набора.
|
||||
- **Чем воспроизведён:** первое — независимо проходом `review-gate`: временный
|
||||
worktree базовой ревизии, `golangci-lint run ./...` без очистки кеша даёт
|
||||
замечание с путём **другого** дерева; после `golangci-lint cache clean` на той
|
||||
же ревизии — `0 issues`. Второе — `tmp/gate/test.log`: `FAIL` в пакете
|
||||
`git.vakhrushev.me/av/healthlog/tmp/adv/oracle`.
|
||||
- **Чем пойман:** обоими случаями — самим гейтом, но **ценой разбора**: краснота
|
||||
выглядела как дефект изменения, и каждый раз пришлось доказывать, что это не
|
||||
он. Ровно та цена, что названа записью 2026-08-04 выше: «краснота по причине,
|
||||
не связанной с изменением, приучает не читать красноту».
|
||||
- **Что изменено:** шаг `lint` получил свой кеш —
|
||||
`GOLANGCI_LINT_CACHE=tmp/gate/golangci`, — то есть прогон стал герметичным по
|
||||
дереву. Цена названа и замерена проходом `ops`: N деревьев × 10–15 МиБ вместо
|
||||
одного общего кеша, штатный трим go-build-формата у него есть.
|
||||
- **Что осталось незакрытым:** `go test ./...` по-прежнему видит черновые
|
||||
go-пакеты в `./tmp`. Убирать за собой обязан тот, кто их создал (в этот раз —
|
||||
проход ревью), и механизма против забывчивости нет. Дешёвый кандидат, если
|
||||
класс повторится: `go test` по явному списку `./cmd/... ./internal/...` вместо
|
||||
`./...`. Не сделано намеренно — один случай не отличим от случайности, а
|
||||
правило, введённое по одному случаю, потом никто не помнит зачем.
|
||||
|
||||
+33
-21
@@ -63,12 +63,10 @@ func (s Style) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalJSON отдаёт род строкой. Нулевое значение уезжает как `unknown`, а не
|
||||
// как пустая строка: клиент не должен видеть в ответе состояние, которого в
|
||||
// словаре нет.
|
||||
func (s Style) MarshalJSON() ([]byte, error) {
|
||||
return []byte(`"` + s.String() + `"`), nil
|
||||
}
|
||||
// Собственной сериализации у Style нет намеренно: строку в ответ кладёт
|
||||
// транспорт (`internal/httpapi`, форма провода). Домен владеет ЗНАЧЕНИЯМИ
|
||||
// словаря, а не их видом на проводе; `String()` при этом нужен и логам, и
|
||||
// сообщениям тестов, и проводу — второго словаря заводить незачем.
|
||||
|
||||
// Параметры измерения. Оба названы числами, а не оставлены на усмотрение вызова,
|
||||
// потому что от них зависят счётчики основания в ответе.
|
||||
@@ -156,6 +154,16 @@ func clipMetric(metric string) string {
|
||||
return metric[:maxMetricInLog] + "…"
|
||||
}
|
||||
|
||||
// ФОРМЫ ПРОВОДА В ЭТОМ ПАКЕТЕ НЕТ, и это решение, а не упущение.
|
||||
//
|
||||
// Типы ниже — форма ответа use-case, а не форма ответа HTTP: `json`-тегов они
|
||||
// не несут и до сериализации не доезжают. Публичный контракт чтения объявляет
|
||||
// транспорт (`internal/httpapi`), поэтому переименование поля здесь байты
|
||||
// ответа клиенту не меняет — оно ломает компиляцию перевода. Обратная цена
|
||||
// названа вслух: новое поле само в ответ не попадёт, его обязан перечислить
|
||||
// транспорт. Решение и цена обеих сторон — `docs/architecture.md`, раздел
|
||||
// «Read API», подраздел «Форма провода».
|
||||
|
||||
// Basis — основание, на котором объявлен род. Числа подобраны так, чтобы их
|
||||
// разности были осмысленны: `Hours − Compared` — часы, отброшенные проверкой
|
||||
// пригодности, `Compared − Agreeing − Conflicting` — часы, не сошедшиеся ни с
|
||||
@@ -165,17 +173,21 @@ func clipMetric(metric string) string {
|
||||
// и «часов не было вовсе» — разные события, и клиент обязан различать их без
|
||||
// второго запроса.
|
||||
type Basis struct {
|
||||
Hours int `json:"hours"`
|
||||
Compared int `json:"compared"`
|
||||
Agreeing int `json:"agreeing"`
|
||||
Conflicting int `json:"conflicting"`
|
||||
FirstHour *time.Time `json:"first_hour"`
|
||||
LastHour *time.Time `json:"last_hour"`
|
||||
Hours int
|
||||
Compared int
|
||||
Agreeing int
|
||||
Conflicting int
|
||||
FirstHour *time.Time
|
||||
LastHour *time.Time
|
||||
}
|
||||
|
||||
// Aggregation — род вместе с основанием.
|
||||
//
|
||||
// Встраивание здесь — удобство домена, а не форма ответа: плоскость объекта
|
||||
// `aggregation` на проводе объявлена транспортом поимённо и от этого
|
||||
// встраивания не зависит.
|
||||
type Aggregation struct {
|
||||
Style Style `json:"style"`
|
||||
Style Style
|
||||
Basis
|
||||
}
|
||||
|
||||
@@ -186,22 +198,22 @@ type Aggregation struct {
|
||||
// часовых объектов здесь нет: объект — деталь хранения, клиент про него не
|
||||
// знает.
|
||||
type LayerRange struct {
|
||||
Layer string `json:"layer"`
|
||||
From time.Time `json:"from"`
|
||||
To time.Time `json:"to"`
|
||||
Points int `json:"points"`
|
||||
Layer string
|
||||
From time.Time
|
||||
To time.Time
|
||||
Points int
|
||||
}
|
||||
|
||||
// Metric — запись каталога.
|
||||
type Metric struct {
|
||||
Metric string `json:"metric"`
|
||||
Metric string
|
||||
// Units — множество различных единиц метрики, отсортированное. Массив, а не
|
||||
// строка: на живом потоке единицы не менялись ни разу, но одна форма поля
|
||||
// для обоих случаев честнее строки, которая при расхождении молча выберет
|
||||
// одно из двух.
|
||||
Units []string `json:"units"`
|
||||
Aggregation Aggregation `json:"aggregation"`
|
||||
Layers []LayerRange `json:"layers"`
|
||||
Units []string
|
||||
Aggregation Aggregation
|
||||
Layers []LayerRange
|
||||
}
|
||||
|
||||
// Snapshot — каталог вместе с версией ответа.
|
||||
|
||||
@@ -271,22 +271,25 @@ func TestMeasureПротиворечиеСПеревесомМгновенной
|
||||
}
|
||||
}
|
||||
|
||||
// Словарь рода живёт в домене, а его вид на проводе объявляет транспорт: род
|
||||
// уезжает клиенту строкой, которую кладёт `internal/httpapi`, вызывая этот же
|
||||
// `String()`. Поэтому проверяется словарь, а не сериализация — второй словарь на
|
||||
// проводе разошёлся бы с этим молча.
|
||||
//
|
||||
// Незнакомое значение даёт `unknown`, а не пустую строку: клиент не должен
|
||||
// видеть состояние, которого в словаре нет.
|
||||
func TestStyleСловарь(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[catalog.Style]string{
|
||||
catalog.Cumulative: `"cumulative"`,
|
||||
catalog.Instant: `"instant"`,
|
||||
catalog.Unknown: `"unknown"`,
|
||||
catalog.Style(42): `"unknown"`,
|
||||
catalog.Cumulative: "cumulative",
|
||||
catalog.Instant: "instant",
|
||||
catalog.Unknown: "unknown",
|
||||
catalog.Style(42): "unknown",
|
||||
}
|
||||
for style, want := range cases {
|
||||
got, err := json.Marshal(style)
|
||||
if err != nil {
|
||||
t.Fatalf("сериализация %v: %v", style, err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("род %d: получили %s, ждали %s", style, got, want)
|
||||
if got := style.String(); got != want {
|
||||
t.Errorf("род %d: получили %q, ждали %q", style, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+105
-8
@@ -2,26 +2,123 @@ package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/catalog"
|
||||
)
|
||||
|
||||
// ФОРМА ПРОВОДА КАТАЛОГА. Публичный контракт объявлен здесь и только здесь —
|
||||
// доменные типы `internal/catalog` `json`-тегов не несут и до сериализации не
|
||||
// доезжают. Отсюда следует то, ради чего это сделано: переименование поля в
|
||||
// домене байты ответа не меняет, а смена контракта есть правка вот этих
|
||||
// объявлений, то есть действие, а не побочный эффект.
|
||||
//
|
||||
// Обратная цена взята сознательно: новое поле домена в ответ само не попадёт —
|
||||
// его обязан перечислить перевод ниже. Решение, цена обеих сторон и разбор
|
||||
// чужих решений — `docs/architecture.md`, раздел «Read API», подраздел «Форма
|
||||
// провода».
|
||||
//
|
||||
// ОБРАЗЕЦ ДЛЯ СЛЕДУЮЩИХ МАРШРУТОВ: точки, тренировки, записи и MCP объявляют
|
||||
// свою форму так же — типами рядом с обработчиком, переводом-присваиванием,
|
||||
// строкой в таблице `wire_internal_test.go`. Выбирать заново не нужно.
|
||||
|
||||
// catalogResponse — оболочка ответа каталога.
|
||||
//
|
||||
// Объект, а не голый массив: список метрик — не единственное, что каталогу
|
||||
// когда-нибудь придётся отдать, а массив верхнего уровня расширить нечем.
|
||||
type catalogResponse struct {
|
||||
Metrics []catalog.Metric `json:"metrics"`
|
||||
Metrics []metricWire `json:"metrics"`
|
||||
}
|
||||
|
||||
// metricWire — запись каталога на проводе.
|
||||
type metricWire struct {
|
||||
Metric string `json:"metric"`
|
||||
Units []string `json:"units"`
|
||||
Aggregation aggregationWire `json:"aggregation"`
|
||||
Layers []layerWire `json:"layers"`
|
||||
}
|
||||
|
||||
// aggregationWire — род агрегации вместе с основанием, ПЛОСКО.
|
||||
//
|
||||
// Поля основания перечислены здесь поимённо, а не встроены структурой: в домене
|
||||
// `Basis` встроен в `Aggregation`, и плоскость объекта была следствием этого
|
||||
// встраивания — разъединение в домене молча дало бы клиенту вложенный объект.
|
||||
// Теперь плоскость — записанное решение транспорта.
|
||||
//
|
||||
// Род — обычная `string`: словарь (`cumulative`/`instant`/`unknown`) клиент
|
||||
// видит строкой, и кладёт её сюда транспорт. Значения словаря при этом остаются
|
||||
// доменные (`catalog.Style.String()`) — свой `switch` здесь завёл бы второй
|
||||
// словарь, который разошёлся бы с первым молча.
|
||||
type aggregationWire struct {
|
||||
Style string `json:"style"`
|
||||
Hours int `json:"hours"`
|
||||
Compared int `json:"compared"`
|
||||
Agreeing int `json:"agreeing"`
|
||||
Conflicting int `json:"conflicting"`
|
||||
FirstHour *time.Time `json:"first_hour"`
|
||||
LastHour *time.Time `json:"last_hour"`
|
||||
}
|
||||
|
||||
// layerWire — разрез метрики на проводе.
|
||||
type layerWire struct {
|
||||
Layer string `json:"layer"`
|
||||
From time.Time `json:"from"`
|
||||
To time.Time `json:"to"`
|
||||
Points int `json:"points"`
|
||||
}
|
||||
|
||||
// catalogWire переводит записи домена в форму провода.
|
||||
//
|
||||
// Чистая функция от уже прочитанного значения: ни `context`, ни хранилища, ни
|
||||
// часов. Версия снимка сюда не идёт — она уезжает в `ETag`, и снимок у тела и у
|
||||
// метки один, иначе `304` подтверждал бы одно состояние, а `200` отдавал другое.
|
||||
//
|
||||
// Присваивание поле в поле, а не копирование структуры: это и есть то место, где
|
||||
// смена контракта становится видимой правкой.
|
||||
func catalogWire(metrics []catalog.Metric) catalogResponse {
|
||||
// Непустой срез, а не nil: nil сериализуется в `null`, и пустая витрина
|
||||
// отдавала бы клиенту `"metrics": null` вместо `[]`. Домен это уже
|
||||
// обеспечивает, но контракт объявлен здесь — значит и держится здесь.
|
||||
out := make([]metricWire, 0, len(metrics))
|
||||
for _, m := range metrics {
|
||||
layers := make([]layerWire, 0, len(m.Layers))
|
||||
for _, l := range m.Layers {
|
||||
layers = append(layers, layerWire{
|
||||
Layer: l.Layer,
|
||||
From: l.From,
|
||||
To: l.To,
|
||||
Points: l.Points,
|
||||
})
|
||||
}
|
||||
// Так же, как слои: `make` + `append`, а не копирование среза домена.
|
||||
// Ветвления «если nil» здесь нет намеренно — оно было бы веткой, которую
|
||||
// сегодня не проходит ни один вход (домен nil не отдаёт), то есть
|
||||
// непроверяемой страховкой. Конструкция даёт непустой срез всегда.
|
||||
units := make([]string, 0, len(m.Units))
|
||||
units = append(units, m.Units...)
|
||||
out = append(out, metricWire{
|
||||
Metric: m.Metric,
|
||||
Units: units,
|
||||
Aggregation: aggregationWire{
|
||||
Style: m.Aggregation.Style.String(),
|
||||
Hours: m.Aggregation.Hours,
|
||||
Compared: m.Aggregation.Compared,
|
||||
Agreeing: m.Aggregation.Agreeing,
|
||||
Conflicting: m.Aggregation.Conflicting,
|
||||
// Указатели переносятся КАК УКАЗАТЕЛИ: разыменование дало бы
|
||||
// `0001-01-01T00:00:00Z` там, где окно не измерено, а
|
||||
// правдоподобная дата в ответе неотличима от настоящей.
|
||||
FirstHour: m.Aggregation.FirstHour,
|
||||
LastHour: m.Aggregation.LastHour,
|
||||
},
|
||||
Layers: layers,
|
||||
})
|
||||
}
|
||||
return catalogResponse{Metrics: out}
|
||||
}
|
||||
|
||||
// handleMetrics отдаёт каталог разрезов с измеренным родом агрегации.
|
||||
//
|
||||
// Список приходит из домена уже непустым срезом: nil сериализуется в `null`, и
|
||||
// пустая витрина отдавала бы клиенту `"metrics": null` вместо `[]`. Тест,
|
||||
// сравнивающий разобранные структуры, этого не увидел бы — потому приёмочная
|
||||
// проверка сравнивает байты ответа. Второй страховки здесь нет намеренно:
|
||||
// подстраховка поверх подстраховки прячет отказ первой.
|
||||
//
|
||||
// Условный запрос стоит ПОСЛЕ проверки токена (её ставит роутер) и ДО сборки
|
||||
// снимка: в этом весь смысл — самый частый запрос потребителя есть повтор
|
||||
// неизменившегося, и он не должен стоить ни снимка, ни разжатия точек.
|
||||
@@ -62,5 +159,5 @@ func (a *api) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
// ответ уходит без метки: это ровно поведение до появления условного
|
||||
// запроса, то есть деградация в безопасную сторону.
|
||||
setReadHeaders(w, etag(scopeMetrics, snap.Version))
|
||||
writeJSON(w, http.StatusOK, catalogResponse{Metrics: snap.Metrics})
|
||||
writeJSON(w, http.StatusOK, catalogWire(snap.Metrics))
|
||||
}
|
||||
|
||||
@@ -41,7 +41,12 @@ func TestКаталогПустойВитриныОтдаётПустойСпи
|
||||
|
||||
// Границы неизмеренного окна уезжают как `null`, а не как правдоподобная метка
|
||||
// `0001-01-01`: нулевое время в ответе неотличимо от данных.
|
||||
func TestКаталогНеизмеренноеОкноОтдаётNull(t *testing.T) {
|
||||
//
|
||||
// Сравниваются БАЙТЫ ЦЕЛОГО ТЕЛА, а не подстроки. Это ветка `*time.Time`, где
|
||||
// перевод домена в форму провода идёт присваиванием поле в поле: разыменуй
|
||||
// указатель — и получишь `"first_hour":"0001-01-01T00:00:00Z"`, в котором
|
||||
// подстрока `"first_hour"` по-прежнему есть, а обещание нарушено.
|
||||
func TestКаталогНеизмеренноеОкноОтдаётБайтыСNull(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
at := time.Date(2026, 6, 1, 10, 0, 0, 0, time.UTC)
|
||||
@@ -53,11 +58,13 @@ func TestКаталогНеизмеренноеОкноОтдаётNull(t *testi
|
||||
t.Fatalf("слияние: %v", err)
|
||||
}
|
||||
|
||||
body := getCatalog(t, h, "").Body.String()
|
||||
for _, want := range []string{`"style":"unknown"`, `"first_hour":null`, `"last_hour":null`, `"hours":0`} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("в ответе нет %s: %s", want, body)
|
||||
}
|
||||
want := `{"metrics":[{"metric":"vo2_max","units":["ml/(kg·min)"],` +
|
||||
`"aggregation":{"style":"unknown","hours":0,"compared":0,"agreeing":0,` +
|
||||
`"conflicting":0,"first_hour":null,"last_hour":null},` +
|
||||
`"layers":[{"layer":"raw","from":"2026-06-01T10:00:00Z","to":"2026-06-01T10:00:00Z","points":1}]}]}`
|
||||
|
||||
if got := strings.TrimSpace(getCatalog(t, h, "").Body.String()); got != want {
|
||||
t.Errorf("форма неизмеренного окна изменилась:\n получили %s\n ждали %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,9 +130,15 @@ func TestТокенЧтенияНеОседаетВУчётеДоставки(t
|
||||
}
|
||||
|
||||
// Форма ответа закреплена БАЙТАМИ на непустой витрине, а не подстроками.
|
||||
// Wire-форма каталога — это доменные структуры с json-тегами, и переименование
|
||||
// поля меняет публичный контракт без единого касания транспорта; страж у него
|
||||
// один — этот литерал.
|
||||
//
|
||||
// Литерал ниже — ДЕТЕКТОР ИЗМЕНЕНИЯ ФОРМЫ, а не сам контракт. Роли разведены
|
||||
// намеренно: источником истины контракта станет рукописная OpenAPI-спека
|
||||
// (решение владельца 2026-08-04, задача `openapi-spec`), а сверку спеки с
|
||||
// маршрутами внесёт в гейт задача `openapi-gate-check`. Детектор при этом
|
||||
// краснеет РАНЬШЕ гейта — в момент правки, — и в этом весь его смысл.
|
||||
//
|
||||
// Правишь литерал — правишь публичный контракт. Значит рядом обязана лежать
|
||||
// правка спеки, а не только «чтобы позеленело».
|
||||
func TestКаталогОтдаётОжидаемыеБайты(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
|
||||
@@ -195,6 +208,11 @@ func TestКаталогПовторяетсяПобайтово(t *testing.T) {
|
||||
|
||||
// Отказ хранилища переводится в 500 с человекочитаемым сообщением: текст ошибки
|
||||
// наружу не уходит — в нём имена колонок и форма запроса.
|
||||
//
|
||||
// Тело отказа сравнивается БАЙТАМИ: у читающего маршрута это такая же часть
|
||||
// публичного контракта, как успешный ответ, и клиент видит его чаще. Проверка
|
||||
// «внутренностей не видно» осталась рядом, но она слабее: подстрок в ответе нет
|
||||
// и у тела, которое переименовало ключ `error`.
|
||||
func TestКаталогОтвечает500НаОтказХранилища(t *testing.T) {
|
||||
h, st, _ := newAPITokens(t, nil, nil)
|
||||
if err := st.Close(); err != nil {
|
||||
@@ -205,7 +223,21 @@ func TestКаталогОтвечает500НаОтказХранилища(t *te
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("статус %d, ждали 500", rec.Code)
|
||||
}
|
||||
if body := rec.Body.String(); strings.Contains(body, "sql") || strings.Contains(body, "bucket") {
|
||||
t.Errorf("наружу уехали внутренности: %s", body)
|
||||
if got := strings.TrimSpace(rec.Body.String()); got != `{"error":"каталог не собрался"}` {
|
||||
t.Errorf("форма тела отказа изменилась: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Форма тела отказа закреплена байтами и на 401 — том коде, который потребитель
|
||||
// видит первым, если ошибся токеном.
|
||||
func TestКаталогОтдаётОжидаемыеБайтыОтказа(t *testing.T) {
|
||||
h, _, _ := newAPITokens(t, nil, []string{"read-token"})
|
||||
|
||||
rec := getCatalog(t, h, "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("статус %d, ждали 401", rec.Code)
|
||||
}
|
||||
if got := strings.TrimSpace(rec.Body.String()); got != `{"error":"неверный или отсутствующий токен"}` {
|
||||
t.Errorf("форма тела отказа изменилась: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,20 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// errorWire — форма провода тела отказа, общая для всех маршрутов.
|
||||
//
|
||||
// Объявленный тип, а не `map[string]string`: у читающего маршрута тело отказа
|
||||
// такая же часть публичного контракта, как и успешный ответ, и клиент видит его
|
||||
// чаще. Карта же делает «два ответа совпадают побайтово» свойством библиотеки, а
|
||||
// не решения, и переименование ключа `error` не увидел бы ни один сторож — ни
|
||||
// обход графа типов (карта строк проходит как стандартный тип), ни байтовый
|
||||
// литерал (тел отказа он не закреплял).
|
||||
type errorWire struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// writeError отдаёт человекочитаемое сообщение, а не текст ошибки: в тексте
|
||||
// имена колонок и форма запроса.
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
writeJSON(w, status, errorWire{Error: msg})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.vakhrushev.me/av/healthlog/internal/catalog"
|
||||
)
|
||||
|
||||
// Форма провода объявлена транспортом, и это утверждается ПРЯМО: ни один тип
|
||||
// домена не достигает сериализации ответа.
|
||||
//
|
||||
// Отсюда и следует свойство, ради которого задача существует: переименование
|
||||
// поля доменного типа байты ответа не меняет — до энкодера этот тип не доезжает
|
||||
// вовсе. Прозой это уже было написано; прозу компилятор не проверяет.
|
||||
//
|
||||
// Прямого «переименуй и посмотри» в Go-тесте не бывает: отказ компиляции
|
||||
// собственного пакета тест не наблюдает, а при неудавшейся компиляции байтов не
|
||||
// существует. Поэтому утверждается эквивалентное и проверяемое — недостижимость
|
||||
// домена из графа типов ответа.
|
||||
|
||||
// modulePrefix — префикс путей пакетов этого модуля.
|
||||
//
|
||||
// Сверка идёт по `PkgPath`, а не по имени пакета: имя совпадает у чужих
|
||||
// пакетов, а алиас (`type wire = catalog.Metric`) имени вообще не меняет. Если
|
||||
// префикс протухнет (переезд модуля), проверка станет вечно зелёной — от этого
|
||||
// и стоит рядом заведомо красный случай.
|
||||
const modulePrefix = "git.vakhrushev.me/av/healthlog/"
|
||||
|
||||
// selfPkg — сам транспорт: его типы и есть объявленная форма провода.
|
||||
var selfPkg = reflect.TypeOf(catalogResponse{}).PkgPath()
|
||||
|
||||
// foreignTypes обходит граф типов значения и собирает типы этого модуля,
|
||||
// объявленные ВНЕ транспорта.
|
||||
//
|
||||
// Обход рекурсивный и покрывает все позиции, в которых доменный тип может
|
||||
// спрятаться: поле структуры (в том числе неэкспортированное и встроенное),
|
||||
// элемент среза и массива, ключ И значение карты, адресат указателя. `seen`
|
||||
// защищает от самоссылающегося типа, а не оптимизирует.
|
||||
//
|
||||
// Исключение одно и оно задано ПО ТИПУ — `json.RawMessage`: дословно
|
||||
// сохранённое содержимое уезжает клиенту сырым JSON (инвариант «точки хранятся
|
||||
// дословно»). Признак «у типа есть свой `MarshalJSON`» исключением быть не мог:
|
||||
// он вернул бы домен на провод ровно тем механизмом, который сняли со `Style`.
|
||||
func foreignTypes(t reflect.Type) []string {
|
||||
var out []string
|
||||
seen := map[reflect.Type]bool{}
|
||||
var walk func(reflect.Type)
|
||||
walk = func(t reflect.Type) {
|
||||
if t == nil || seen[t] {
|
||||
return
|
||||
}
|
||||
seen[t] = true
|
||||
if t == reflect.TypeOf(json.RawMessage(nil)) {
|
||||
return
|
||||
}
|
||||
if p := t.PkgPath(); strings.HasPrefix(p, modulePrefix) && p != selfPkg {
|
||||
out = append(out, t.String()+" ← "+p)
|
||||
return
|
||||
}
|
||||
// Своя структура обязана объявить имя КАЖДОГО экспортированного поля
|
||||
// тегом. Без этого требования в обход есть тихая калитка: определённый
|
||||
// тип поверх доменной структуры (`type pointWire store.Point`) числится
|
||||
// транспортным по `PkgPath`, обход через него не идёт — а ключи ответа
|
||||
// оказываются именами полей домена, то есть ровно та связь, которую
|
||||
// задача разрывала. Поймано проходом `specs` на профиле `deep`.
|
||||
// Пустой `PkgPath` — безымянная структура: она тоже часть формы провода,
|
||||
// раз доехала сюда по графу, и правило на неё распространяется.
|
||||
if t.Kind() == reflect.Struct && (t.PkgPath() == selfPkg || t.PkgPath() == "") {
|
||||
for i := range t.NumField() {
|
||||
f := t.Field(i)
|
||||
if f.PkgPath != "" || f.Anonymous {
|
||||
continue // неэкспортированное `encoding/json` не пишет
|
||||
}
|
||||
if _, ok := f.Tag.Lookup("json"); !ok {
|
||||
out = append(out, t.String()+"."+f.Name+" — поле формы провода без json-тега")
|
||||
}
|
||||
}
|
||||
}
|
||||
switch t.Kind() {
|
||||
case reflect.Pointer, reflect.Slice, reflect.Array:
|
||||
walk(t.Elem())
|
||||
case reflect.Map:
|
||||
walk(t.Key())
|
||||
walk(t.Elem())
|
||||
case reflect.Struct:
|
||||
for i := range t.NumField() {
|
||||
walk(t.Field(i).Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(t)
|
||||
return out
|
||||
}
|
||||
|
||||
// Таблица образцов ответа. СЛЕДУЮЩИЙ МАРШРУТ ДОБАВЛЯЕТ СЮДА СТРОКУ — точки,
|
||||
// тренировки, записи, статистика. Выбирать решение заново не нужно.
|
||||
func TestФормаПроводаДоменаНеСодержит(t *testing.T) {
|
||||
cases := map[string]any{
|
||||
"каталог": catalogResponse{},
|
||||
"тело отказа": errorWire{},
|
||||
"учёт приёма": ingestResponse{},
|
||||
}
|
||||
|
||||
for name, sample := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if bad := foreignTypes(reflect.TypeOf(sample)); len(bad) > 0 {
|
||||
t.Errorf("доменные типы в графе ответа: %v", bad)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// metricAlias — алиас доменного типа, объявленный в транспорте. Существует
|
||||
// только ради отрицательного контроля ниже.
|
||||
type metricAlias = catalog.Metric
|
||||
|
||||
// layerDefined — ОПРЕДЕЛЁННЫЙ тип поверх доменной структуры (не алиас): пакет у
|
||||
// него транспортный, поля — доменные и без тегов. Тоже только для контроля.
|
||||
type layerDefined catalog.LayerRange
|
||||
|
||||
// Заведомо красный случай. Проверка, доказывающая ОТСУТСТВИЕ, зелена и будучи
|
||||
// сломанной: протухший `modulePrefix`, пропущенная позиция обхода, перепутанное
|
||||
// сравнение — всё это выглядит как «доменных типов нет». В проекте этот класс
|
||||
// уже дважды всплывал (docs/conventions/testing.md: отрицательный контроль для
|
||||
// правил порядка, утверждение о таблице-константе).
|
||||
func TestОбходГрафаТиповНаходитДомен(t *testing.T) {
|
||||
type embedded struct{ catalog.Aggregation }
|
||||
|
||||
cases := map[string]any{
|
||||
"поле": struct{ M catalog.Metric }{},
|
||||
"неэкспортированное": struct{ m catalog.Metric }{}, //nolint:unused // позиция обхода, а не поле
|
||||
"срез": struct{ M []catalog.Metric }{},
|
||||
"массив": struct{ M [2]catalog.LayerRange }{},
|
||||
"указатель": struct{ M *catalog.Metric }{},
|
||||
"значение карты": struct{ M map[string]catalog.Metric }{},
|
||||
"ключ карты": struct{ M map[catalog.Style]int }{},
|
||||
"встроенное": embedded{},
|
||||
"вложенная анонимная": struct{ Inner struct{ M catalog.Metric } }{},
|
||||
// Алиас — самая тихая позиция: имя пакета у него транспортное, а
|
||||
// `PkgPath` доменный. Обход, сверяющий имя пакета, пропустил бы её.
|
||||
"алиас": struct{ M metricAlias }{},
|
||||
"перечисление": struct{ S catalog.Style }{},
|
||||
// Определённый тип поверх доменной структуры: по `PkgPath` он
|
||||
// транспортный, обход внутрь не идёт, а ключи ответа — имена полей
|
||||
// домена. Ловится требованием тега на каждом экспортированном поле.
|
||||
"определённый тип поверх домена": layerDefined{},
|
||||
"поле формы провода без тега": struct{ M string }{},
|
||||
}
|
||||
|
||||
for name, sample := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if bad := foreignTypes(reflect.TypeOf(sample)); len(bad) == 0 {
|
||||
t.Error("обход не нашёл доменный тип — проверка ничего не доказывает")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Перевод отдаёт пустые коллекции списком, а не отсутствием, — на входе, где
|
||||
// домен отдал `nil`.
|
||||
//
|
||||
// Утверждение живёт здесь, а не в маршрутном тесте: домен сегодня `nil` не
|
||||
// отдаёт, поэтому через HTTP этот вход недостижим, а правило принадлежит
|
||||
// проводу и обязано держаться независимо от того, что домен обещает сейчас.
|
||||
// Четыре следующих маршрута копируют именно `catalogWire`.
|
||||
func TestПереводОтдаётПустыеКоллекцииСписком(t *testing.T) {
|
||||
got, err := json.Marshal(catalogWire([]catalog.Metric{{Metric: "vo2_max"}}))
|
||||
if err != nil {
|
||||
t.Fatalf("сериализация: %v", err)
|
||||
}
|
||||
|
||||
want := `{"metrics":[{"metric":"vo2_max","units":[],` +
|
||||
`"aggregation":{"style":"unknown","hours":0,"compared":0,"agreeing":0,` +
|
||||
`"conflicting":0,"first_hour":null,"last_hour":null},"layers":[]}]}`
|
||||
|
||||
if string(got) != want {
|
||||
t.Errorf("пустые коллекции:\n получили %s\n ждали %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Пустой каталог — `[]`, а не `null`: nil-срез сериализуется в `null`, и
|
||||
// клиент прочитал бы «поля нет» вместо «метрик нет».
|
||||
func TestПереводПустогоКаталогаОтдаётСписок(t *testing.T) {
|
||||
got, err := json.Marshal(catalogWire(nil))
|
||||
if err != nil {
|
||||
t.Fatalf("сериализация: %v", err)
|
||||
}
|
||||
if string(got) != `{"metrics":[]}` {
|
||||
t.Errorf("пустой каталог: получили %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Самоссылающийся тип обход не зацикливает: без `seen` это бесконечная
|
||||
// рекурсия, а не медленный тест.
|
||||
func TestОбходГрафаТиповНеЗацикливается(t *testing.T) {
|
||||
type node struct {
|
||||
Next *node `json:"next"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
if bad := foreignTypes(reflect.TypeOf(node{})); len(bad) > 0 {
|
||||
t.Errorf("чужие типы: %v", bad)
|
||||
}
|
||||
}
|
||||
|
||||
// Стандартная библиотека проводу разрешена, и это надо утверждать: правило
|
||||
// звучит как «домена в графе нет», а не «в графе нет ничего чужого».
|
||||
// `json.RawMessage` — то самое исключение по типу, ради которого существует
|
||||
// инвариант «точки хранятся дословно».
|
||||
func TestОбходГрафаТиповПропускаетСтандартныеТипы(t *testing.T) {
|
||||
sample := struct {
|
||||
At time.Time `json:"at"`
|
||||
Ptr *time.Time `json:"ptr"`
|
||||
Raw json.RawMessage `json:"raw"`
|
||||
Units []string `json:"units"`
|
||||
}{}
|
||||
if bad := foreignTypes(reflect.TypeOf(sample)); len(bad) > 0 {
|
||||
t.Errorf("стандартные типы объявлены чужими: %v", bad)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-08-04
|
||||
@@ -0,0 +1,356 @@
|
||||
## Context
|
||||
|
||||
Читающий маршрут сегодня один — `GET /api/v1/metrics`. Его тело собирается
|
||||
прямой сериализацией доменных типов `internal/catalog`: `Metric`, `LayerRange`,
|
||||
`Aggregation`, `Basis` несут `json`-теги, `Style` несёт `MarshalJSON`, а
|
||||
транспорт владеет только оболочкой `{"metrics": …}` (66 строк
|
||||
`internal/httpapi/catalog.go`).
|
||||
|
||||
Из этого следуют три пути смены публичного контракта **без единого касания
|
||||
транспорта**, и все три выглядят как внутренняя правка домена:
|
||||
|
||||
1. переименование поля (`Metric.Metric` → `Metric.Name`);
|
||||
2. **разъединение встроенной структуры** — `Aggregation` встраивает `Basis`, и
|
||||
плоскость объекта `aggregation` на проводе есть следствие этого встраивания;
|
||||
вынеси `Basis` полем — и клиент получит вложенный объект;
|
||||
3. появление внутреннего поля в доменном типе — оно уезжает клиенту само.
|
||||
|
||||
Удерживает контракт один литерал в `TestКаталогОтдаётОжидаемыеБайты`, и нигде не
|
||||
сказано, что этот литерал и есть контракт.
|
||||
|
||||
Со следующей задачи цели образец копируют точки, тренировки, записи и MCP. Два
|
||||
факта проекта делают выбор не вкусовым:
|
||||
|
||||
- **`internal/httpapi/ingest.go` уже владеет своим типом ответа**
|
||||
(`ingestResponse` с `json`-тегами). То есть образец «форма провода принадлежит
|
||||
транспорту» в проекте **уже есть**, и каталог от него отклоняется, а не
|
||||
наоборот.
|
||||
- **Хранилище уже применяет ровно предлагаемое решение.** `store.Point`
|
||||
(`internal/store/bucket.go:24-29`) не несёт `json`-тегов вовсе; формат
|
||||
сжатого `payload` объявлен **отдельным неэкспортированным** типом
|
||||
`storedPoint` (`json:"s"/"e"/"o"/"p"`, там же, `:774-782`), а `encodePayload`
|
||||
переводит одно в другое **полем в поле**. То есть образец «своя форма —
|
||||
своим типом с явным переводом» в проекте есть и в записи тоже; каталог
|
||||
отклоняется от него, а не наоборот.
|
||||
- **Правило «доменный тип и есть форма провода» ломается на втором же маршруте
|
||||
цели.** Провод точек обещан как `{ts, tz_offset, units, values}`
|
||||
(`docs/architecture.md`, раздел «Форма ответа»), а `store.Point` несёт
|
||||
`{Start, End, OffsetSeconds, Raw}` — эти два набора не совпадают **ни одним
|
||||
именем**. Доменный тип формой провода там быть не может даже при желании.
|
||||
|
||||
(Первая редакция этого абзаца утверждала, что `store.Point` «уже потратил
|
||||
свои `json`-теги» на формат хранения. Утверждение неверно, поймано проходом
|
||||
`specs` на профиле `design` и заменено на проверяемое; вывод от этого стал
|
||||
сильнее, а не слабее.)
|
||||
|
||||
Схема не трогается, миграции нет, витрина только читается.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- Публичный контракт чтения перестаёт быть следствием формы доменных типов.
|
||||
- Правило одинаково применимо к четырём оставшимся маршрутам цели и к MCP.
|
||||
- Правило выражено **проверкой**, а не прозой: забыть его нельзя молча.
|
||||
- Байты ответа каталога не меняются — правка структурная, и это утверждается.
|
||||
- Решение и цена **обеих** сторон записаны там, где их найдёт следующая задача.
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- Версионирование контракта (`/api/v2`, conversion-слой). Потребителей нет,
|
||||
ломать нечего.
|
||||
- Машиночитаемая схема контракта — это отдельная цель
|
||||
([self-description](../../../docs/tasks/items/self-description.md)).
|
||||
- Правка формы ответа каталога по существу: имена и состав полей остаются.
|
||||
- Разбор запроса. Речь только об **ответе**: у каталога параметров нет, а
|
||||
разбор параметров точек — задача точек.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Прежде своего — prior art
|
||||
|
||||
Вопрос «домен или DTO на проводе» решался десятки раз, и литература по нему
|
||||
**расколота**. Обе стороны названы поимённо, потому что обе живые.
|
||||
|
||||
**Домен = провод.**
|
||||
|
||||
- **Ben Johnson, «Standard Package Layout» и `benbjohnson/wtf`** — доменные типы
|
||||
корневого пакета несут `json`-теги напрямую, и HTTP-слой кодирует их без
|
||||
промежуточного типа; собственную структуру (`findDialsResponse`) транспорт
|
||||
заводит только там, где домена не хватает на **конверт**. Это ровно наше
|
||||
сегодняшнее состояние, включая оболочку `catalogResponse`.
|
||||
- **Prometheus, `web/api/v1`** — смешанный вид: конверт (`Response`,
|
||||
`QueryData`) объявлен API-слоем, а полезная нагрузка внутри (`parser.Value`,
|
||||
`stats.QueryStats`) сериализуется доменными типами напрямую.
|
||||
|
||||
**Домен и провод раздельны.**
|
||||
|
||||
- **Gitea** — `modules/structs` (алиас `api`) отделён от `models`.
|
||||
- **Docker** — `api/types`, разбитый по областям, отдельно от движка.
|
||||
- **go-kit (Peter Bourgon)** — service → endpoint → transport, «business logic
|
||||
should have no knowledge of endpoint- or transport-domain concepts».
|
||||
- **Kubernetes** — самая жёсткая форма: internal-типы против версионированных
|
||||
`k8s.io/api` плюс кодогенерируемая конверсия.
|
||||
- **etcd** — `etcdserverpb` против `mvccpb`; разделение достаётся побочным
|
||||
эффектом protobuf, а не отдельным решением.
|
||||
- **Mat Ryer, «How I write HTTP services in Go after 13 years»** — ортогональный
|
||||
совет: типы запроса и ответа, нужные одному обработчику, объявляются **рядом с
|
||||
ним**, чтобы никто снаружи не начал опираться на форму, которую автор не
|
||||
считает устойчивой. Названа и цена: трение, когда те же типы понадобились
|
||||
тесту.
|
||||
|
||||
**Что из этого взято и что отвергнуто.**
|
||||
|
||||
- **Взят вид Gitea/Docker/Prometheus-конверта в редакции Райера**: типы провода
|
||||
объявляются транспортом, живут рядом со своим обработчиком и неэкспортированы.
|
||||
Это же продолжает собственный образец проекта — `ingestResponse`.
|
||||
- **Отвергнут `wtf`-вид (домен = провод)** — не «нам не нравится», а по факту:
|
||||
он неприменим ко второму маршруту цели, потому что поля `store.Point` не
|
||||
совпадают с обещанным проводом точек ни одним именем. Правило, которое
|
||||
ломается на первом же копировании, правилом не является.
|
||||
- **Отвергнут go-kit** — слой `endpoint` покупает мультитранспортность и
|
||||
middleware-набор; у нас транспорта полтора (HTTP и MCP тем же процессом), а
|
||||
middleware даёт `chi`. Цена — три слоя и типы запроса/ответа на каждый метод.
|
||||
- **Отвергнут вид Kubernetes** — версионирование плюс конверсия — это
|
||||
подсистема, а не приём; она окупается там, где контракт обязан жить в
|
||||
нескольких версиях одновременно. У нас ноль потребителей и один владелец.
|
||||
- **Отвергнут etcd/protobuf-first** — транспорт задан снаружи: HAE шлёт JSON,
|
||||
агент читает JSON, MCP говорит JSON. Разделение через protobuf куплено бы
|
||||
зависимостью, которая никому здесь не нужна.
|
||||
|
||||
### Чем это сторожится: байтовый детектор сегодня, рукописная спека потом
|
||||
|
||||
Разделение само по себе контракта не сторожит — оно лишь делает его смену
|
||||
**явной правкой транспорта**. Нужен ещё детектор. И роли здесь надо развести
|
||||
поимённо, потому что в этом же спринте владелец уже решил вторую половину
|
||||
вопроса.
|
||||
|
||||
- **Источник истины контракта — рукописная OpenAPI-спека.** Решение владельца
|
||||
2026-08-04, задача [openapi-spec](../../../docs/tasks/items/openapi-spec.md);
|
||||
сверку спеки с маршрутами вносит в гейт задача
|
||||
[openapi-gate-check](../../../docs/tasks/items/openapi-gate-check.md), и один
|
||||
из её критериев приёмки — «переименованное поле ответа красит гейт».
|
||||
- **Байтовое утверждение — детектор изменения формы, а не сам контракт.** Оно
|
||||
краснеет **в момент правки**, до всякого гейта, и в этом его роль; называть
|
||||
его «единственным утверждением контракта» нельзя — через две задачи спринта
|
||||
это станет ложью, и автор правки обновит литерал, не тронув рукописную спеку.
|
||||
- **`golang.org/x/exp/apidiff` и `go-apidiff` отвергнуты, и причина
|
||||
измерима:** они сравнивают **Go-API** на предмет компилируемости клиентского
|
||||
кода. Смена строки тега (`json:"metric"` → `json:"name"`) при неизменном
|
||||
Go-имени поля для них — не изменение вовсе. То есть ровно тот класс, ради
|
||||
которого заводится сторож, они не видят.
|
||||
- **`swaggo`/генерация OpenAPI из кода отвергнута** — и это отказ от
|
||||
*генерации*, а не от OpenAPI: генератор фотографирует то, что уже изменилось,
|
||||
то есть опечатка в имени поля становится частью спеки. Проект пошёл ровно
|
||||
обратным путём (спека первична коду, как и в OpenSpec).
|
||||
- **Взят golden-подход** (`goldie`, `nao1215/golden` и их обычай `-update`):
|
||||
побайтовое сравнение целого ответа. Библиотека при этом не берётся — литерал
|
||||
в тесте читается в диффе лучше, чем бинарный `testdata`-файл, а `-update` у
|
||||
литерала есть по построению (его правит рука). Метод числа: сегодняшний
|
||||
литерал `internal/httpapi/catalog_test.go` — **401 байт**, это фикстура одной
|
||||
метрики с двумя слоями, а не ответ живого корпуса; для ответа с десятками
|
||||
метрик вывод «литерал читается лучше файла» не мерялся, и у точек его
|
||||
придётся принять заново (см. Risks).
|
||||
|
||||
### Форма: типы провода в файле маршрута, перевод — явная функция
|
||||
|
||||
`internal/httpapi/catalog.go` объявляет `metricWire`, `layerWire`,
|
||||
`aggregationWire` и функцию перевода. Сигнатура выбрана одна и повторяется
|
||||
дословно во всех документах изменения:
|
||||
|
||||
```go
|
||||
func catalogWire(metrics []catalog.Metric) catalogResponse
|
||||
```
|
||||
|
||||
Чистая функция от уже прочитанного значения: без `context`, без хранилища, без
|
||||
часов. Версия ответа в тело не идёт — она уезжает в `ETag`, и снимок у обоих
|
||||
один (`catalog.Snapshot`), так что `304` и `200` описывают одно состояние.
|
||||
|
||||
Поля перечисляются **плоско и поимённо**: плоскость объекта `aggregation`
|
||||
перестаёт быть следствием встраивания `Basis` в домене и становится записанным
|
||||
решением транспорта.
|
||||
|
||||
Тело отказа тоже получает объявленный тип: `writeError` сегодня собирает его из
|
||||
`map[string]string` (`internal/httpapi/httpapi.go:161-163`). Карта на проводе
|
||||
делает «два ответа совпадают побайтово» свойством библиотеки, а не решения, и
|
||||
переименование ключа `error` не видит ни один сторож. Байты при этом те же —
|
||||
`{"error":"…"}`.
|
||||
|
||||
Типы неэкспортированы и объявлены на уровне пакета, а не внутри обработчика
|
||||
(Райер советует внутри). Причина названа им же — трение с тестом: утверждение
|
||||
«домен на провод не попадает» обязано пройти по графу типов ответа, и
|
||||
функционально-локальный тип этому графу недоступен. Плата за отступление
|
||||
маленькая: пакет `httpapi` внутренний, «снаружи» здесь на один слой ближе.
|
||||
|
||||
Перевод — **явное присваивание поле в поле**. Отсюда симметрия, ради которой всё
|
||||
и делается: контракт не меняется случайно **ни в одну сторону** — ни
|
||||
переименование в домене не доезжает до клиента, ни новое доменное поле не
|
||||
уезжает клиенту само.
|
||||
|
||||
### `Style` теряет `MarshalJSON`, но сохраняет `String`
|
||||
|
||||
`aggregationWire.Style` — обычная `string`, и кладёт её транспорт. Значения
|
||||
словаря (`cumulative`/`instant`/`unknown`) при этом остаются в домене:
|
||||
`Style.String()` нужен логам и сообщениям тестов, и провод зовёт **его же** —
|
||||
второго словаря не заводится.
|
||||
|
||||
Граница названа вслух, потому что она нарочно неполна: домен владеет
|
||||
**значениями** словаря, но не их сериализацией. Правка `String()` ради
|
||||
читаемости лога изменит тело ответа клиенту — этот путь остаётся, и сторожит
|
||||
его байтовое утверждение. Альтернатива (свой `switch` на проводе) сторожит
|
||||
лучше, но заводит второй словарь, который разъедется с первым молча; из двух
|
||||
цен взята эта, и она здесь записана, а не умолчана.
|
||||
|
||||
### Проверка, выражающая правило: домен не достигает энкодера
|
||||
|
||||
Утверждение пишется **прямо** и механически: пройти по графу типов значения
|
||||
ответа и убедиться, что ни один тип в нём не принадлежит внутреннему пакету
|
||||
проекта, кроме самого `httpapi`. Тогда «переименование поля доменного типа не
|
||||
меняет байты ответа» — не обещание, а следствие: доменный тип до сериализации не
|
||||
доезжает вовсе.
|
||||
|
||||
Обход рекурсивен (структуры, срезы, массивы, ключи и значения карт, указатели,
|
||||
встроенные и неэкспортированные поля), защищён от самоссылающихся типов и живёт
|
||||
во внутреннем тесте пакета — снаружи `catalogResponse` не виден. Принадлежность
|
||||
пакету определяется по `PkgPath` с префиксом module path, а не по имени пакета:
|
||||
алиас (`type wire = catalog.Metric`) обход тогда не пропускает. Помощник
|
||||
пишется общим по значению ответа и применяется **таблицей** «маршрут → образец
|
||||
ответа»: следующие четыре маршрута добавляют строку, а не выбирают заново.
|
||||
|
||||
Стандартная библиотека проходит: `time.Time` сериализуется своим устойчивым
|
||||
контрактом (RFC 3339), `json.RawMessage` — дословный проброс, ради которого
|
||||
существует инвариант «точки хранятся дословно». Исключение задаётся **по типу**
|
||||
(`json.RawMessage`), а не по признаку «есть свой `MarshalJSON`»: второй признак
|
||||
вернул бы домен на провод ровно тем механизмом, который только что сняли со
|
||||
`Style`.
|
||||
|
||||
**Заведомо красный случай обязателен.** Проверка, доказывающая **отсутствие**
|
||||
чего-либо, зелена и будучи сломанной; у проекта это уже дважды всплывало
|
||||
(`docs/conventions/testing.md`: отрицательный контроль для правил порядка,
|
||||
утверждение о таблице-константе). Поэтому рядом стоит кейс с фикстурой,
|
||||
содержащей `catalog.Metric`, и ожиданием отказа — он же ловит устаревший
|
||||
префикс module path.
|
||||
|
||||
### Куда пишется решение
|
||||
|
||||
Два адреса, и они разные по назначению.
|
||||
|
||||
- `docs/architecture.md`, раздел «Read API», подраздел «Форма провода»: сам
|
||||
выбор, **цена обеих сторон** и ссылка сюда за prior art. Обоснование в
|
||||
`architecture.md` не переезжает — он обзор (CLAUDE.md).
|
||||
- `docs/conventions/testing.md`: правило о **механизме** проверки — байтовое
|
||||
утверждение на каждую различимую форму ответа и заведомо красный случай для
|
||||
структурной проверки. Дом у такого правила там, а не в спеке: спека нормирует
|
||||
наблюдаемое поведение системы, а не устройство репозитория. Оба прохода
|
||||
профиля `design` (`specs` и `architecture`) назвали это независимо, и дельта
|
||||
переписана — в ней остались только требования, у которых есть предмет для
|
||||
сверки «спека → код».
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Новое доменное поле не доезжает до клиента молча** → это цена, взятая
|
||||
сознательно, и она обратная сегодняшней. Смягчение: перевод — одна функция в
|
||||
одном файле, а байтовое утверждение показывает **текущий** контракт целиком,
|
||||
так что «поле не доехало» видно на первом же прогоне, который его ждёт.
|
||||
- **Двойное объявление формы (домен + провод)** → цена в строках: у каталога
|
||||
это четыре типа и одна функция перевода; числом её здесь не называем, потому
|
||||
что мерить пока нечего — счёт станет известен по факту правки. Обратная цена
|
||||
(контракт как побочный эффект имён полей домена) уже измерена в постановке
|
||||
как причина задачи.
|
||||
- **Райер советует локальные типы, взяты пакетные** → чуть шире область
|
||||
видимости внутри `httpapi`. Отступление названо, причина — доступность графу
|
||||
типов в тесте.
|
||||
- **Проверка «домен не достигает энкодера» обходит `reflect.Type`, а не
|
||||
значения** → она слепа к типам, достижимым только через `any`/интерфейс, и к
|
||||
типам **внешних зависимостей**: чужой тип с чужими тегами обход пройдёт. У
|
||||
читающих маршрутов таких полей нет и не планируется; появятся — правило
|
||||
придётся усилить, и это названо здесь, а не обнаружится потом.
|
||||
- **Байтовое утверждение растёт вместе с ответом** → у точек тело может быть
|
||||
крупным, и вывод «литерал читается лучше файла», снятый на 401 байте
|
||||
каталога, там придётся принять заново. Смягчение: утверждать байты **малого**
|
||||
ответа (несколько точек), а объём проверять отдельно.
|
||||
- **Словарь рода остаётся в домене** → правка `Style.String()` меняет тело
|
||||
ответа. Смягчение и его предел названы в решении выше; сторожит байтовое
|
||||
утверждение.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
Миграции нет. Схема, витрина и архив не трогаются, конфиг не меняется. Откат —
|
||||
обратный коммит: наружу не уезжает ничего, кроме тех же байтов ответа.
|
||||
|
||||
## Open Questions
|
||||
|
||||
Три вопроса, каждый — решение не этой задачи. Первые два принесло ревью кода
|
||||
(профиль `deep`), их оракулы прогнаны и лежат в
|
||||
[review/triage.md](review/triage.md).
|
||||
|
||||
### 1. Метка года 10000, принятая приёмом, навсегда убивает маршрут чтения
|
||||
|
||||
**Что решить:** чинить ли парой `FormatTime`/`ParseTime` замкнутость, отбором на
|
||||
границе разбора HAE, или обоими.
|
||||
|
||||
**Путь (прогнан проходом `adversary`, оракул — падающий тест):** тело доставки с
|
||||
`"date": "9999-12-31 23:00:00 -0700"` принимается (200), в UTC это **год
|
||||
10000**, `store.FormatTime` молча печатает `"10000-01-01T06:00:00Z"` в
|
||||
`hour_utc`/`first_ts`/`last_ts`, а `store.ParseTime` (RFC 3339) на чтении
|
||||
отказывает — `GET /api/v1/metrics` отвечает 500 **по всем метрикам**. Состояние
|
||||
переживает пересборку: строка в витрине, тело в архиве, `import + replay`
|
||||
воспроизводит её.
|
||||
|
||||
**Цена вариантов.** Чинить **только** `ParseTime` нельзя: отказ `json.Marshal`
|
||||
станет достижим (`writeJSON` при ошибке кодирования отдаёт **200 с пустым
|
||||
телом** — статус и `ETag` уже отправлены), а сравнение горизонта идёт как TEXT
|
||||
(`hour_utc <= ?`), и `"10000-…"` лексикографически **меньше** `"2026-…"` — то
|
||||
есть защита «свидетельство из будущего свидетельством не является» на этом входе
|
||||
не срабатывает вовсе.
|
||||
|
||||
**Почему не здесь:** весь задействованный код (`internal/hae`, `internal/store`)
|
||||
лежит вне рамок задачи («трогается только каталог и его транспорт»), а правка
|
||||
правила разбора тянет за собой триггер прохода `reimpl` и обязательные прогоны
|
||||
`verify:archive` / `verify:busy`.
|
||||
|
||||
**Рекомендация:** отдельная задача, секция ядро, до выкладки наружу — сегодня
|
||||
контур чтения открыт одной принятой доставкой.
|
||||
|
||||
### 2. Таблица образцов сторожа — opt-in, и забытая строка молчит
|
||||
|
||||
**Что решить:** проверять ли полноту таблицы «маршрут → образец ответа» в
|
||||
`wire_internal_test.go` машиной.
|
||||
|
||||
**Цена вариантов.** (а) обход роутера (`chi.Walk`) с утверждением, что число
|
||||
читающих маршрутов равно числу строк таблицы — около 15 строк, забывание
|
||||
краснеет, цена — сцепка теста с роутером; (б) тестовый hook в `writeJSON`,
|
||||
собирающий типы реально закодированных ответов — ноль мест на новый маршрут, но
|
||||
шов в продакшн-коде; (в) оставить на спеке `read-api` и комментарии-образце —
|
||||
ноль строк сейчас, одна молчащая дыра на каждый забытый маршрут.
|
||||
|
||||
**Что стоит, пока решения нет:** сторож покрывает три типа, идущие через
|
||||
`writeJSON` сегодня (каталог, тело отказа, учёт приёма). Впереди четыре маршрута
|
||||
и MCP — четыре шанса забыть.
|
||||
|
||||
**Рекомендация:** (а). Это ровно тот класс «проверка отсутствия зелена и будучи
|
||||
сломанной», против которого это же изменение завело конвенцию заведомо красного
|
||||
случая; на полноту таблицы конвенция не распространена.
|
||||
|
||||
### 3. Что в проекте считается спекой — контракт системы или ещё и дисциплина его смены
|
||||
|
||||
Вопрос процесса, адресован владельцу. Проход `architecture` назвал это развилкой
|
||||
на профиле `design`; здесь она разрешена в сторону «спека нормирует
|
||||
наблюдаемое, дисциплина живёт в конвенциях», потому что этот выбор дешевле
|
||||
откатить (правило переносится между двумя файлами) и потому что у второго
|
||||
варианта нет предмета для сверки «спека → код». Прецедент задан на четыре
|
||||
следующие задачи цели — если владелец решит иначе, переносить придётся их все.
|
||||
|
||||
---
|
||||
|
||||
Развилка «домен или транспорт» решена выше **фактом** (поля `store.Point` не
|
||||
совпадают с обещанным проводом точек ни одним именем), а не предпочтением.
|
||||
|
||||
Открытым остаётся один вопрос **процесса**, и он адресован владельцу, а не этой
|
||||
задаче: **что в проекте считается спекой — контракт системы или ещё и
|
||||
дисциплина его смены.** Проход `architecture` назвал это развилкой; здесь она
|
||||
разрешена в сторону «спека нормирует наблюдаемое, дисциплина живёт в
|
||||
конвенциях», потому что этот выбор дешевле откатить (правило переносится между
|
||||
двумя файлами) и потому что у второго варианта нет предмета для сверки
|
||||
«спека → код». Прецедент при этом задан на четыре следующие задачи цели — если
|
||||
владелец решит иначе, переносить придётся их все.
|
||||
@@ -0,0 +1,75 @@
|
||||
## Why
|
||||
|
||||
Публичный контракт единственного читающего маршрута сегодня **выведен из формы
|
||||
доменных типов**: `internal/catalog` сам несёт `json`-теги, а
|
||||
`internal/httpapi/catalog.go` владеет только оболочкой `{"metrics": …}`.
|
||||
Переименование поля в домене, разъединение встроенного `Basis` или добавление
|
||||
внутреннего поля меняют байты ответа, не касаясь транспорта; удерживает это один
|
||||
литерал в тесте `TestКаталогОтдаётОжидаемыеБайты`, и о том, что он и есть
|
||||
контракт, в коде не сказано нигде.
|
||||
|
||||
Пока маршрут один и потребителей у него нет, цена нулевая. Со следующей задачи
|
||||
цели образец копируют точки, тренировки, записи и MCP — четыре маршрута и второй
|
||||
транспорт. Решение принимается **до** копирования: потом это будет не решение, а
|
||||
археология. Момент выбран ещё и потому, что менять контракт каталога сейчас
|
||||
бесплатно — снаружи его не читает никто.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Форма провода объявляется транспортом, а не выводится из домена.** Каждый
|
||||
читающий маршрут `internal/httpapi` объявляет собственные типы ответа с
|
||||
`json`-тегами; доменные типы `internal/catalog` тегов лишаются, перевод —
|
||||
явная функция транспорта.
|
||||
- **Доменные типы каталога перестают быть публичным контрактом.** `Metric`,
|
||||
`LayerRange`, `Aggregation`, `Basis` остаются формой ответа **use-case**, а не
|
||||
формой ответа HTTP. `Style.MarshalJSON` убирается: сериализацию рода кладёт
|
||||
транспорт. Значения словаря (`cumulative`/`instant`/`unknown`) при этом
|
||||
остаются в домене — провод зовёт `Style.String()`, чтобы не завести второй
|
||||
словарь; граница и её цена названы в `design.md`.
|
||||
- **Тело отказа тоже получает объявленный тип.** Сегодня `writeError` собирает
|
||||
его из `map[string]string`; у читающего маршрута тело отказа — часть того же
|
||||
контракта, и оно не должно оставаться единственным местом, куда не смотрит ни
|
||||
один сторож. Байты те же.
|
||||
- **Байты ответа каталога не меняются.** Правка структурная; контракт из
|
||||
требования «Форма ответа каталога» остаётся тем же до символа, и это
|
||||
утверждается тестом, а не намерением.
|
||||
- **Заводится проверка, прямо утверждающая разделение:** ни один тип домена не
|
||||
достигает сериализации ответа — обход графа типов значения ответа, с заведомо
|
||||
красным случаем рядом. Отсюда и следует, что переименование поля домена до
|
||||
провода не доезжает.
|
||||
- **Решение и цена обеих сторон записываются** в `docs/architecture.md`
|
||||
(раздел Read API); правило о **механизме** проверки — в
|
||||
`docs/conventions/testing.md`, где у правил такого рода дом.
|
||||
- Схема не трогается, миграции нет, данные только читаются.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `read-api`: общие правила читающих маршрутов, поверх которых встают точки,
|
||||
тренировки, записи и MCP. Два правила: **публичный контракт меняется только
|
||||
правкой транспорта** (форма провода объявляется явно и не выводится из формы
|
||||
доменных типов) и **пустая коллекция — список, а не отсутствие**.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
Нет. Поведение каталога не меняется: байты ответа, заголовки и коды остаются
|
||||
прежними, требование «Форма ответа каталога» из `catalog` продолжает описывать
|
||||
их без единой правки. Это и есть проверяемое утверждение изменения.
|
||||
|
||||
## Impact
|
||||
|
||||
- `internal/catalog/catalog.go` — снятие `json`-тегов с `Basis`, `Aggregation`,
|
||||
`LayerRange`, `Metric`; удаление `Style.MarshalJSON`.
|
||||
- `internal/catalog/measure_test.go` — `TestStyleСловарь` переезжает с
|
||||
`MarshalJSON` на `String()`.
|
||||
- `internal/httpapi/catalog.go` — типы провода каталога и перевод из домена.
|
||||
- `internal/httpapi/httpapi.go` — объявленный тип тела отказа вместо карты.
|
||||
- `internal/httpapi/catalog_test.go` — байтовые утверждения формы (измеренное
|
||||
окно, неизмеренное окно, пустая витрина).
|
||||
- `internal/httpapi/wire_internal_test.go` — обход графа типов ответа и заведомо
|
||||
красный случай к нему.
|
||||
- `docs/architecture.md` — раздел Read API: решение с ценой обеих сторон.
|
||||
- `docs/conventions/testing.md` — правило о механизме: байты на каждую
|
||||
различимую форму ответа, заведомо красный случай для структурной проверки.
|
||||
- Схема, витрина, архив, конфиг — не трогаются.
|
||||
@@ -0,0 +1,271 @@
|
||||
# Триаж ревью — `forma-provoda-chteniya`
|
||||
|
||||
## Сводка
|
||||
|
||||
- **Профиль:** `deep`, режим `по графу`. База диффа
|
||||
`bd832337df6dae5829432a9ee021b380cc6be1b1`, изменения застейджены.
|
||||
- **Гейт:** зелёный, 13/13 шагов, покрытие изменённых строк 100%.
|
||||
- **Проходы поимённо, с исходом** (состав сверен с профилем: `deep` = 6 кодовых
|
||||
проходов + `reimpl` по триггеру + триаж; расхождения с профилем нет):
|
||||
- `gate` — отработал, 2 находки (1 починена инлайн, починка проверена триажем);
|
||||
- `specs` (код против спек) — отработал, 2 находки, обе починены инлайн,
|
||||
починки проверены мутациями; `openspec validate --strict` — valid;
|
||||
- `code` (прозаические конвенции) — отработал, 1 находка, дубль находки 1
|
||||
`specs` (найден независимо), закрыт той же починкой;
|
||||
- `adversary` — отработал, 3 построенных пути (1 major, 2 minor) — **все три
|
||||
унаследованы от базы, диффом не внесены** (проверено триажем по списку
|
||||
файлов диффа и по диффу `internal/catalog/catalog.go`); плюс секция
|
||||
«атаковано и не пробилось» и 3 свойства без построенного пути;
|
||||
- `ops` — отработал, 1 находка minor, две гипотезы закрыты замерами;
|
||||
- `architecture` (по коду) — отработал, 1 находка minor с готовой развилкой;
|
||||
- `reimpl` — **не запускался**: триггер «новое правило слияния, идентичности
|
||||
или разбора» не сработал — дифф не трогает `internal/store`, `internal/fold`,
|
||||
`internal/hae` и схему (проверено списком файлов диффа);
|
||||
- `triage` (этот отчёт) — ничего нового не ищет по определению; пропуск
|
||||
любого прохода — его пропуск тоже.
|
||||
- На профиле `design` (до кода) отдельно отработали `specs`, `rubric`,
|
||||
`architecture`; их находки закрыты в предложении, рубрика легла критериями
|
||||
Р1–Р12 в `tasks.md`.
|
||||
- **Счёт находок:** на вход пришло 10 именованных находок и 3 свойства без
|
||||
построенного пути. После дедупликации по причине — 8 уникальных
|
||||
(`code` = `specs`-1; «ссылка на архив» у `architecture` = находка 2 `gate`).
|
||||
Из них 3 починены инлайн и проверены триажем мутациями. Открытых осталось 6:
|
||||
3 в секции «Стоит исправить сейчас», 3 унаследованных/задокументированных —
|
||||
строками в границах покрытия (в урожай). Гипотез — 2, promote — 3.
|
||||
|
||||
### Проверка инлайн-починок (не по пересказу — прогонами)
|
||||
|
||||
1. **Байтовые утверждения тела отказа** — есть
|
||||
(`internal/httpapi/catalog_test.go:216,233`). Мутация
|
||||
`json:"error"` → `json:"message"` в изолированной копии дерева →
|
||||
`TestКаталогОтдаётОжидаемыеБайтыОтказа` красный. До починки эта мутация была
|
||||
зелёной (оракул `specs`). **Починено.**
|
||||
2. **Требование `json`-тега на полях формы провода** — есть
|
||||
(`internal/httpapi/wire_internal_test.go:72-82`), в таблице заведомо красных
|
||||
13 контролей, включая «определённый тип поверх домена» и «поле без тега».
|
||||
Две мутации в копии: (а) поле `aggregationWire.Hours` без тега →
|
||||
`TestФормаПроводаДоменаНеСодержит` красный; (б) `type layerWire
|
||||
catalog.LayerRange` с конверсией вместо перевода полем в поле → красный и
|
||||
сторож (4 поля «без json-тега»), и байтовые тесты. Ровно та калитка из
|
||||
находки 2 `specs` закрыта. **Починено.**
|
||||
3. **`GOLANGCI_LINT_CACHE` в `scripts/gate.py`** — есть (строка 119, кеш в
|
||||
`tmp/gate/golangci`), `env` корректно сливается с `os.environ`
|
||||
(`run()`, строка 70). `ops` отдельно замерил: кеш не растёт без предела,
|
||||
повторный прогон 0.57 с. **Починено.**
|
||||
|
||||
Плюс контрольный прогон триажа: `go build ./...`, `go vet ./...`,
|
||||
`go test ./internal/...` — целиком зелёные на застейдженном дереве.
|
||||
|
||||
---
|
||||
|
||||
## Блокирует мердж
|
||||
|
||||
Пусто. Единственный `major` прогона (путь `adversary` про метку года 10000)
|
||||
воспроизведён триажем и **не принадлежит этому мерджу** — см. первый пункт
|
||||
следующей секции: весь задействованный код (`internal/hae/hae.go`,
|
||||
`internal/store/store.go`, `internal/store/catalog.go`) вне диффа, дифф его
|
||||
поведения не меняет, а починка расширила бы scope задачи («трогается только
|
||||
каталог и его транспорт») и по триггеру потребовала бы `reimpl` и
|
||||
`verify:archive`/`verify:busy`. Прецедент дисциплины тот же, что в записи
|
||||
2026-08-03 `docs/review.md`: краснота унаследована — заводится задачей, а не
|
||||
чинится попутно.
|
||||
|
||||
Формулировка «критичных проблем не обнаружено» здесь не употребляется — что
|
||||
именно проверено и что не могло быть проверено, см. «Границы покрытия».
|
||||
|
||||
## Стоит исправить сейчас
|
||||
|
||||
### 1. Одна принятая доставка с меткой года ≥10000 навсегда убивает маршрут чтения — 500 по всем метрикам
|
||||
|
||||
- Файл: `internal/hae/hae.go:812` → `internal/store/store.go:263` →
|
||||
`internal/store/catalog.go:174-182` → `internal/httpapi/catalog.go`
|
||||
- Severity: major (унаследовано от базы, диффом не внесено)
|
||||
- Confidence: high
|
||||
- Оракул: падающий тест `adversary` (прогнан) + независимое воспроизведение
|
||||
триажем на уровне механизма: `time.Parse("2006-01-02 15:04:05 -0700",
|
||||
"9999-12-31 23:00:00 -0700")` принимается, `FormatTime` → `"10000-01-01T06:00:00Z"`,
|
||||
`ParseTime` (RFC 3339) на этой строке отказывает
|
||||
(`cannot parse "0-01-01T06:00:00Z" as "-"`). Побочно подтверждено:
|
||||
`"10000-…" < "2026-…"` как TEXT — защита горизонта «свидетельство из
|
||||
будущего» на этом входе не срабатывает.
|
||||
- Последствие: строка уже в витрине и в архиве, `import + replay` её
|
||||
воспроизводит; лечится правкой кода **и** ручной правкой
|
||||
`./data/healthlog.db` — необратимой операцией из списка запретов. Класс
|
||||
«порча с низкой вероятностью» — выше любого гарантированного неудобства.
|
||||
- Ловушка починки (названа `adversary`, сохранить в задаче): чинить только со
|
||||
стороны `ParseTime` нельзя — отказ `json.Marshal` станет достижим, и
|
||||
`writeJSON` отдаст 200 с пустым телом; плюс откроется дыра TEXT-горизонта.
|
||||
Чинить надо на приёме области значений (разбор HAE / `FormatTime`) вместе с
|
||||
горизонтом.
|
||||
- Действие: **развилка.** Вопрос владельцу: (а) завести отдельную задачу в
|
||||
каталоге — рекомендация триажа: правка трогает разбор, по триггеру тянет
|
||||
`reimpl` + `verify:archive`/`verify:busy`, в рамки этой задачи не лезет;
|
||||
(б) чинить в этой ветке с расширением scope и полным повторным циклом;
|
||||
(в) принять риск без задачи — не рекомендуется: отказ молчащий до первого
|
||||
чтения, а лечение требует необратимой ручной операции.
|
||||
|
||||
### 2. Ссылка на архивный путь change с угаданной датой умрёт молча
|
||||
|
||||
- Файл: `docs/architecture.md:1638`
|
||||
(`openspec/changes/archive/2026-08-04-forma-provoda-chteniya/design.md`)
|
||||
- Severity: minor (внесено диффом)
|
||||
- Confidence: high
|
||||
- Оракул: пути не существует (проверено `ls`); ссылка инлайн-кодом, `docs.py
|
||||
check` её не проверяет; дата архивации угадана — архивирование не сегодня
|
||||
ломает её без сигнала. Дубль: `gate` и `architecture` нашли независимо.
|
||||
- Последствие: разбор чужих решений (единственное место, где записано «почему
|
||||
не как у Prometheus/Kubernetes») станет недостижим из обзора.
|
||||
- Действие: **инлайн.** Убрать угаданную дату: сослаться на change по имени
|
||||
(`openspec/changes/forma-provoda-chteniya/design.md`, поправить при
|
||||
архивации) либо назвать документ словами без пути — на вкус оркестратора.
|
||||
|
||||
### 3. Маршрут, забывший строку в таблице образцов, останется без стража молча
|
||||
|
||||
- Файл: `internal/httpapi/wire_internal_test.go` (таблица `cases`,
|
||||
`TestФормаПроводаДоменаНеСодержит`)
|
||||
- Severity: minor (внесено диффом — сторож новый, и он opt-in)
|
||||
- Confidence: high
|
||||
- Оракул: прогон `architecture` — новый обработчик с `catalog.Metric` в ответе,
|
||||
не добавленный в таблицу, не красит ни один тест; байтовый литерал нового
|
||||
маршрута закрепил бы доменные имена как норму.
|
||||
- Последствие: забытая строка неотличима от отсутствия проблемы — ровно тот
|
||||
класс, против которого это же изменение завело конвенцию заведомо красного
|
||||
случая. Впереди четыре маршрута и MCP, каждый копирует образец.
|
||||
- Действие: **развилка.** Варианты (сформулированы `architecture`):
|
||||
(а) тест полноты таблицы через `chi.Walk` — ~15 строк, забывание краснеет,
|
||||
цена — сцепка теста с роутером; (б) тестовый hook в `writeJSON` — ноль мест
|
||||
на новый маршрут, цена — шов в продакшн-коде; (в) оставить как есть — ноль
|
||||
строк, одна молчащая дыра на каждый забытый маршрут. Триаж склоняется к (а):
|
||||
сцепка с роутером дешевле шва в продакшне и дешевле молчащей дыры, но выбор
|
||||
затрагивает форму, которую скопируют пять раз, — потому развилка, не инлайн.
|
||||
|
||||
## Гипотезы без доказательства
|
||||
|
||||
- **`catalogWire` делит `*time.Time` со снимком (нет глубокой копии).**
|
||||
Понижено до nit: построенного пути нет — сегодня снимок не кешируется и
|
||||
алиасинг ненаблюдаем; станет наблюдаемым с первым кешем снимка. Источник —
|
||||
`adversary`, «свойства без построенного пути». Не действие, а память: при
|
||||
задаче о кеше перечитать.
|
||||
- **Ненайденный маршрут пишет сырой `r.URL.Path` в лог уровня INFO.** Понижено
|
||||
до nit: путь до значения точки или токена через URL не построен (токены в
|
||||
заголовке), последствия не названо. Источник — `adversary`.
|
||||
|
||||
Ничего из `critical`/`major` понижать не пришлось: единственный `major` получил
|
||||
оракул (дважды — проходом и триажем). Согласие проходов нигде не считалось
|
||||
подтверждением: дубль `specs`+`code` закрыт одной починкой и проверен мутацией,
|
||||
а не «подтверждён двумя проходами».
|
||||
|
||||
## Promote candidates
|
||||
|
||||
1. **Правило линтера: экспортированные типы вне `internal/httpapi` не несут
|
||||
`json`-тегов.** Сторож графа типов ослепнет, если доменная структура
|
||||
когда-нибудь получит теги обратно, — обход в неё не пойдёт как в «свою».
|
||||
Механизируемо (forbidigo/ручное правило по AST); источник — `adversary`.
|
||||
2. **Записать трактовку «метка времени точки — координата, а не значение».**
|
||||
До WARN/ERROR доезжают `last_ts` и текст `parse time "10000-…"`; `adversary`
|
||||
назвал это границей, которую проект перешёл сознательно, но трактовка нигде
|
||||
не записана — следующий прогон поднимет её заново. Место —
|
||||
`docs/security.md` или `docs/conventions/logging.md`.
|
||||
3. **Проверка инлайн-код-ссылок на существование пути в `docs.py check`.**
|
||||
Находка 2 существует ровно потому, что ссылка инлайн-кодом невидима
|
||||
проверке раскладки. Механизируемо; цена — ложные срабатывания на
|
||||
намеренно-будущих путях, потому promote, а не инлайн.
|
||||
|
||||
## Границы покрытия
|
||||
|
||||
**Прогон.** Профиль `deep`, режим «по графу». Запускались: `gate`, `specs`,
|
||||
`code`, `adversary`, `ops`, `architecture`, триаж. На профиле `design` до кода —
|
||||
`specs`, `rubric`, `architecture`.
|
||||
|
||||
**Не запускалось и почему:**
|
||||
|
||||
- `reimpl` — триггер «новое правило слияния, идентичности или разбора» не
|
||||
сработал: дифф не трогает `internal/store`, `internal/fold`, `internal/hae`,
|
||||
миграций нет (сверено триажем по списку файлов диффа, не по пересказу);
|
||||
- `task verify:archive` и `task verify:busy` — изменение не трогает разбор,
|
||||
идентичность, слияние и схему; по правилу гейта их гоняет человек или
|
||||
оркестратор перед изменением этих правил. Если развилка 1 решится вариантом
|
||||
(б) — оба прогона и `reimpl` становятся обязательными;
|
||||
- `rubric` на коде — по составу конвейера намеренно (судить код по критерию,
|
||||
под который он писался, — корреляция по построению); его свойства лежат
|
||||
критериями Р1–Р12 в `tasks.md` и проверены `specs` поимённо.
|
||||
|
||||
**Не влезло в потолок / унаследовано — в урожай задач, не выброшено:**
|
||||
|
||||
- каталог отдаёт 8 единиц из 12 без признака урезания
|
||||
(`internal/catalog/catalog.go`, `sortedKeys`, потолок 8) — minor, оракул
|
||||
прогнан `adversary`, унаследовано от базы (участок диффом не тронут);
|
||||
- два WARN (`future data`, `aggregation style conflict`) стоят в теле обработки
|
||||
запроса — частота равна частоте чтений, условие не гаснет; проект правило
|
||||
«постоянный WARN обесценивает уровень» для себя уже формулировал (`fold.go`) —
|
||||
minor, оракул прогнан (10 запросов → 10 WARN), унаследовано;
|
||||
- сторож графа типов слеп к полям `any`/`interface{}` — воспроизведено `ops`
|
||||
собственным тестом; риск назван в `design.md` (Risks) этим же изменением,
|
||||
вероятный путь эволюции (`json.RawMessage`) слепую зону обходит. Слепая зона
|
||||
сторожа, а не дефект диффа.
|
||||
|
||||
**Что запущенные проходы не могли проверить в принципе (из charter'ов):**
|
||||
|
||||
- `gate` — только механизируемое: смысл контракта, полноту таблицы образцов и
|
||||
честность спек он не видит;
|
||||
- `specs` — исключение для дословного содержимого (`json.RawMessage`) проверено
|
||||
только на синтетике; MCP кода не имеет; четыре из пяти читающих маршрутов не
|
||||
написаны — какие байты они отдадут, проверять нечего; какие поля несёт тело
|
||||
отказа, дельта не говорит; правило «отсутствие как `null`» имеет предмет
|
||||
только у `*time.Time`;
|
||||
- `adversary` — доказывает существование пути, не отсутствие: «атаковано и не
|
||||
пробилось» (побайтовый повтор ×50, `304` 0/12, `*time.Time`, значение в теле
|
||||
отказа, отказ `Marshal`) — это невыведенные атаки, а не гарантия;
|
||||
- `ops` — поведение под реальным потоком телефона не наблюдал; откат бинаря
|
||||
проверен рассуждением и байтовой сверкой, не прогоном на живом стенде;
|
||||
- `architecture` — соответствие реализации дельте по пунктам не его работа
|
||||
(названо им самим про `scripts/gate.py`, которого нет в `Impact`
|
||||
предложения; `specs` это покрыл: изоляция кеша — находка `gate`, а не
|
||||
самодеятельность);
|
||||
- триаж — ничего нового не находит по определению; пропуск любого прохода —
|
||||
его пропуск тоже.
|
||||
|
||||
**Ослабленный оракул замеров.** На машине фоново живут чужой процесс
|
||||
`healthlog serve` на `:18099` и рабочий контейнер на `:8080`; в замерах они не
|
||||
участвовали, но общий шедулинг ОС разбавляет числа `ops` (6.16 мкс/оп на живом
|
||||
размере каталога, 305 мкс на пределе, 0.57 с повторный прогон линтера) —
|
||||
порядки надёжны, точные значения нет.
|
||||
|
||||
**Целиком на человеке — «Недоступно проверке» из `docs/review.md`, двумя
|
||||
списками, не слитыми.**
|
||||
|
||||
*Не проверит ни один проход:*
|
||||
|
||||
- реальный профиль нагрузки: телефон шлёт молча и непрерывно, объём и частота
|
||||
меряются только по факту;
|
||||
- поведение приложения HAE за пределами наблюдённого — расписание
|
||||
автоматизаций пожелание, а не гарантия (разведка, находка 28);
|
||||
- полнота словаря переводов после обновления iOS;
|
||||
- секции, которых поток ещё не приносил: `symptoms`, `ecg`,
|
||||
`heartRateNotifications`, `cycleTracking`, `medications` — разбор писался
|
||||
вслепую, проход судит форму кода, не соответствие реальности.
|
||||
|
||||
*Перестали проверять сознательно:*
|
||||
|
||||
- прогон живого архива (`task verify:archive`) и свёртка под удерживаемой
|
||||
блокировкой (`task verify:busy`) в гейт не входят (минута и ~50 секунд,
|
||||
данные только на этой машине); гоняет человек перед задачей, трогающей
|
||||
разбор или слияние (записи 2026-08-02…04 — три красноты этого класса подряд);
|
||||
- класс «в Go так не пишут» — поимённая сверка с Effective Go, Go Code Review
|
||||
Comments, стайлгайдами Uber и Google — не покрыт вовсе после упразднения
|
||||
`idiom`;
|
||||
- класс «чего нет в зрелой реализации такого узла» — вне профиля `design`.
|
||||
|
||||
*Плюс общее, что не покрывает никакой конвейер:* история инцидентов, поведение
|
||||
под реальным потоком, поведение внешних систем в их версиях, завязка
|
||||
потребителей на текущее поведение (у каталога потребителей сегодня нет — задача
|
||||
на этом стоит, и это проверяемо лишь отсутствием знания о чужих проектах) и
|
||||
вопрос «нужна ли эта функциональность вообще».
|
||||
|
||||
**Документы проекта.** Всё, что нужно триажу, на месте: `CLAUDE.md` с
|
||||
инвариантами и severity, `docs/review.md` со всеми четырьмя нужными разделами
|
||||
(журнал, типовые ложноположительные, вопросы к проходам, «Недоступно
|
||||
проверке»), `docs/security.md`, `docs/conventions/`. Ни один проход о
|
||||
недостающем документе не заявил; отсев ложноположительных шёл по заполненному
|
||||
проектному списку (совпадений находок с ним не было — выбрасывать по нему
|
||||
ничего не пришлось). Строк деградации в этом прогоне нет.
|
||||
@@ -0,0 +1,97 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Публичный контракт читающего маршрута меняется только правкой транспорта
|
||||
|
||||
Система SHALL объявлять форму ответа каждого читающего маршрута типами
|
||||
транспортного слоя и MUST NOT выводить её из формы доменных типов: ни один тип
|
||||
домена MUST NOT достигать сериализации ответа, а перевод домена в форму провода
|
||||
MUST быть явным перечислением полей.
|
||||
|
||||
**Читающий маршрут** здесь — тот, что отдаёт наружу состояние витрины: каталог,
|
||||
точки, тренировки, записи, статистика. `/healthz` под правило не подпадает —
|
||||
его тело есть литеральный признак живости процесса, а не данные.
|
||||
|
||||
**MCP собственной формы провода не объявляет.** Адаптер переводит вызовы в те
|
||||
же обработчики (`docs/architecture.md`, раздел «MCP»), поэтому объявленная форма
|
||||
у контракта одна на оба транспорта. Второе объявление развело бы их молча.
|
||||
|
||||
Правило распространяется и на **тело отказа**: у читающего маршрута оно часть
|
||||
того же контракта, и клиент видит его чаще успешного ответа. Тело отказа MUST
|
||||
собираться объявленным типом транспорта, а не картой и не свободной строкой.
|
||||
|
||||
**Следствие, ради которого правило и существует:** переименование поля
|
||||
доменного типа, разъединение встроенной в него структуры и появление в нём
|
||||
нового поля байты ответа не меняют — до сериализации доменный тип не доезжает.
|
||||
Смена публичного контракта становится правкой транспортного слоя, то есть
|
||||
действием, а не побочным эффектом.
|
||||
|
||||
**Цена названа и взята сознательно, потому что она обратная.** Новое поле
|
||||
домена не попадает в ответ само: чтобы клиент его увидел, транспорт обязан его
|
||||
перечислить. Контракт перестаёт меняться случайно в обе стороны, и это дороже
|
||||
ровно на объём перевода.
|
||||
|
||||
Из правила есть одно исключение, и оно ограничено **содержимым**, а не
|
||||
конвертом: значение, которое хранилище держит дословно, уезжает клиенту сырым
|
||||
JSON без разбора и переобъявления — переписывать его в тип провода значило бы
|
||||
нарушить инвариант «точки хранятся дословно». Конверт вокруг такого значения —
|
||||
метки времени, офсет, единицы, слой — объявляется типом провода и нормализуется,
|
||||
как того требует инвариант «форма Apple не транслируется».
|
||||
|
||||
#### Scenario: Поле доменного типа переименовано
|
||||
|
||||
- **GIVEN** поле доменного типа, из которого собирается ответ, переименовано, а
|
||||
транспортный слой не тронут
|
||||
- **WHEN** маршрут отвечает на прежний запрос при прежнем состоянии витрины
|
||||
- **THEN** байты ответа те же
|
||||
|
||||
#### Scenario: Форма провода изменена намеренно
|
||||
|
||||
- **GIVEN** транспорт переименовал поле объявленной формы
|
||||
- **WHEN** маршрут отвечает на прежний запрос при прежнем состоянии витрины
|
||||
- **THEN** байты ответа изменились, и изменение целиком лежит в правке
|
||||
транспортного слоя
|
||||
|
||||
#### Scenario: Читающий маршрут отвечает отказом
|
||||
|
||||
- **WHEN** читающий маршрут отвечает кодом отказа
|
||||
- **THEN** тело отказа собрано объявленным типом транспорта
|
||||
|
||||
#### Scenario: Словарь значения виден клиенту строкой
|
||||
|
||||
- **GIVEN** доменное перечисление, чьи значения клиент видит строками
|
||||
- **WHEN** маршрут отвечает
|
||||
- **THEN** строку в ответ кладёт транспорт, а домен собственной сериализации не
|
||||
несёт
|
||||
|
||||
#### Scenario: Дословное содержимое проходит насквозь
|
||||
|
||||
- **WHEN** в ответ идёт значение, сохранённое хранилищем дословно
|
||||
- **THEN** оно уезжает сырым JSON, а конверт вокруг него объявлен типом провода
|
||||
|
||||
### Requirement: Пустая коллекция чтения — список, а не отсутствие
|
||||
|
||||
Система SHALL отдавать пустую коллекцию читающего маршрута как `[]` и MUST NOT
|
||||
отдавать её как `null`; отсутствующее значение MUST отдаваться как `null` и
|
||||
MUST NOT подменяться нулевым значением своего типа.
|
||||
|
||||
Правило общее для всех читающих маршрутов, потому что цена у него одна:
|
||||
`nil`-срез сериализуется в `null`, и клиент читает «поля нет» там, где на самом
|
||||
деле «элементов нет»; правдоподобная нулевая дата в ответе неотличима от
|
||||
настоящей. Конкретные коллекции и поля каждого маршрута нормирует его
|
||||
собственная capability — здесь только правило, чтобы следующий маршрут не
|
||||
принимал его заново.
|
||||
|
||||
Ручной перевод домена в форму провода делает это правило не теоретическим:
|
||||
присваивание поле в поле копирует `nil` и разыменовывает указатель ровно так,
|
||||
как написано, и обе ошибки молчат.
|
||||
|
||||
#### Scenario: Коллекция ответа пуста
|
||||
|
||||
- **WHEN** в ответ читающего маршрута идёт коллекция без единого элемента
|
||||
- **THEN** она уезжает как `[]`
|
||||
|
||||
#### Scenario: Значения нет
|
||||
|
||||
- **WHEN** поле ответа не имеет значения
|
||||
- **THEN** оно уезжает как `null`, а не как нулевая метка времени, пустая
|
||||
строка или ноль
|
||||
@@ -0,0 +1,126 @@
|
||||
## 1. Домен перестаёт быть формой провода
|
||||
|
||||
- [x] 1.1 `internal/catalog`: снять `json`-теги с `Basis`, `Aggregation`,
|
||||
`LayerRange`, `Metric`; комментарии типов переписать так, чтобы они называли
|
||||
их формой ответа use-case, а не формой ответа HTTP
|
||||
- [x] 1.2 `internal/catalog`: удалить `Style.MarshalJSON`; `String()` оставить —
|
||||
он нужен логам и сообщениям тестов, и провод зовёт его же
|
||||
- [x] 1.3 `internal/catalog/measure_test.go`: `TestStyleСловарь` переезжает с
|
||||
`MarshalJSON` на `String()`, значения словаря те же
|
||||
|
||||
## 2. Форма провода в транспорте
|
||||
|
||||
- [x] 2.1 `internal/httpapi/catalog.go`: типы `metricWire`, `layerWire`,
|
||||
`aggregationWire` с `json`-тегами; поля `aggregation` перечислены **плоско**,
|
||||
а не встраиванием — плоскость перестаёт быть следствием формы домена
|
||||
- [x] 2.2 перевод ровно этой сигнатуры (одна на все документы изменения):
|
||||
`func catalogWire(metrics []catalog.Metric) catalogResponse` — чистая функция
|
||||
без `context`, без хранилища, без часов; род кладётся строкой через
|
||||
`Style.String()`; пустые коллекции — `[]`, отсутствующие метки — `null`
|
||||
- [x] 2.3 `handleMetrics` собирает ответ вызовом `catalogWire`; версия снимка
|
||||
по-прежнему уходит только в `ETag`, в тело не попадает
|
||||
- [x] 2.4 `internal/httpapi/httpapi.go`: тело отказа собирается объявленным
|
||||
типом вместо `map[string]string`; байты те же — `{"error":"…"}`
|
||||
|
||||
## 3. Проверки
|
||||
|
||||
- [x] 3.1 `internal/httpapi/wire_internal_test.go` (внутренний тест пакета):
|
||||
обход графа типов значения ответа — поля, срезы, массивы, ключи и значения
|
||||
карт, указатели, встроенные и неэкспортированные поля; защита от
|
||||
самоссылающегося типа; принадлежность определяется по `PkgPath` с префиксом
|
||||
module path, а не по имени пакета
|
||||
- [x] 3.2 обход применён **таблицей** «маршрут → образец ответа»: каталог, тело
|
||||
отказа. Следующий маршрут добавляет строку
|
||||
- [x] 3.3 **заведомо красный случай**: на фикстуре, содержащей `catalog.Metric`,
|
||||
обход обязан быть красным. Он же ловит устаревший префикс module path
|
||||
- [x] 3.4 исключение задано **по типу** `json.RawMessage`, а не по признаку
|
||||
«есть свой `MarshalJSON`»; кейс с доменным типом, несущим `MarshalJSON`, —
|
||||
красный
|
||||
- [x] 3.5 `TestКаталогОтдаётОжидаемыеБайты` остаётся; литерал не правится ни
|
||||
одним символом, комментарий называет его **детектором изменения формы**, а
|
||||
источником истины — рукописную OpenAPI-спеку (задача `openapi-spec`)
|
||||
- [x] 3.6 `TestКаталогНеизмеренноеОкноОтдаётNull` переписывается с подстрок на
|
||||
**байты целого тела**: это ветка `*time.Time`, где ручной перевод и создаёт
|
||||
риск подставить `0001-01-01` вместо `null`
|
||||
- [x] 3.7 байты пустого каталога (`{"metrics":[]}`) остаются утверждением
|
||||
- [x] 3.8 форма провода утверждается **ровно в одном месте** на форму ответа:
|
||||
второго утверждения той же формы через разобранную структуру или подстроку в
|
||||
пакете нет
|
||||
|
||||
## 4. Документы
|
||||
|
||||
- [x] 4.1 `docs/architecture.md`, раздел «Read API»: подраздел «Форма провода» —
|
||||
решение, **цена обеих сторон**, ссылка на `design.md` изменения за prior art
|
||||
- [x] 4.2 `docs/conventions/testing.md`: правило о механизме — байтовое
|
||||
утверждение на **каждую различимую** форму ответа; структурная проверка,
|
||||
доказывающая отсутствие, несёт заведомо красный случай
|
||||
- [x] 4.3 `openspec validate --strict forma-provoda-chteniya` зелёный
|
||||
|
||||
## 5. Верификация
|
||||
|
||||
- [x] 5.1 `task gate` зелёный
|
||||
- [x] 5.2 поведенческая: сервис поднят на своём порту и своей базе в `./tmp`.
|
||||
Сравниваются **два ответа одного прогона** — снятый с базовой ревизии и
|
||||
снятый с ветки против **одного и того же** файла базы при неработающем
|
||||
приёме. Числа ответа (`points`, `hours`, границы) печатаются, но в
|
||||
утверждении не участвуют: все они производны от корпуса. `ETag` сравним
|
||||
только внутри одного часа — горизонт измерения входит в метку и едет вместе
|
||||
с часами
|
||||
|
||||
## Критерии приёмки задачи
|
||||
|
||||
Из `docs/tasks/items/read-api-wire-format.md`, дословно:
|
||||
|
||||
- [x] решение записано в `docs/architecture.md` с названной ценой обеих сторон,
|
||||
а не только выбранной — оракул: глазами по разделу
|
||||
- [x] переименование поля доменного типа либо не меняет байты ответа, либо
|
||||
меняет их намеренно, и тест утверждает это прямо, а не проверяет непустоту —
|
||||
оракул задачи назван как «тест на переименование»; **исполнимая его форма** —
|
||||
обход графа типов (3.1) плюс заведомо красный случай (3.3) плюс байтовые
|
||||
литералы (3.5–3.7). Прямого «переименуй и посмотри» в Go-тесте не бывает:
|
||||
отказ компиляции собственного пакета тест не наблюдает, а при неудавшейся
|
||||
компиляции байтов не существует вовсе. Подмена оракула названа здесь, а не
|
||||
сделана молча
|
||||
- [x] маршрут каталога приведён к решению, и следующий маршрут копирует
|
||||
образец, а не выбирает заново — оракул: гейт зелёный плюс сверка
|
||||
`httpapi/catalog.go` с записанным решением
|
||||
|
||||
## Приёмочные критерии от ревью предложения (профиль `design`)
|
||||
|
||||
Рубрика прохода `review-rubric`, порождённая **до** чтения кода. Порядок —
|
||||
по важности.
|
||||
|
||||
- [x] Р1 байты ответа не изменились, и это **измерено**: прежний байтовый
|
||||
литерал не правится ни одним символом; поведенческая сверка сравнивает два
|
||||
ответа **одного прогона**, а не ответ с записанным ранее эталоном
|
||||
- [x] Р2 **оба сторожа доказали, что умеют краснеть**: обход графа типов красен
|
||||
на заведомо доменном значении, байтовое утверждение красно при переименовании
|
||||
тега в типе провода
|
||||
- [x] Р3 обход полон по позициям (поле, срез, массив, ключ и значение карты,
|
||||
указатель, встроенное поле, неэкспортированное поле, анонимная вложенная
|
||||
структура), не зацикливается на рекурсивном типе, а «внутренний пакет
|
||||
проекта» определяется по `PkgPath` с префиксом module path, а не по имени
|
||||
пакета; алиас доменного типа обход **не** пропускает
|
||||
- [x] Р4 пути в обход сторожа названы поимённо (`any`/интерфейс, собственный
|
||||
`MarshalJSON`, тип внешней зависимости), а исключение для дословного
|
||||
содержимого сужено **до типа** `json.RawMessage`
|
||||
- [x] Р5 вырожденные значения закреплены байтами: `{"metrics":[]}`, пустые
|
||||
коллекции как `[]`, неизмеренное окно как `null`, `"style":"unknown"`
|
||||
- [x] Р6 ответ детерминирован: форма провода не содержит `map`, два вызова на
|
||||
одном входе дают те же байты
|
||||
- [x] Р7 перевод — **чистая функция от снимка**: без `context`, без хранилища,
|
||||
без часов; тело и валидатор `ETag` выводятся из одного снимка
|
||||
- [x] Р8 ни одно утверждение не пришпилено к числу, производному от размера
|
||||
корпуса, и к ходу часов; метки времени фикстур — литералы
|
||||
- [x] Р9 форма провода утверждается **ровно в одном месте**, и это место
|
||||
названо комментарием
|
||||
- [x] Р10 отказные ответы принадлежат той же объявленной форме провода
|
||||
- [x] Р11 образец масштабируется: помощник применяется таблицей «маршрут →
|
||||
образец ответа», следующий маршрут добавляет строку
|
||||
- [x] Р12 транспорт не выносит значения точек, имена метрик и токен в лог выше
|
||||
`DEBUG` и не вкладывает доменное значение в текст ошибки
|
||||
|
||||
**Посылка поведенческой сверки, названная рядом с ней (Р1, Р8):** оба ответа
|
||||
снимаются в одном прогоне против одного и того же файла базы при неработающем
|
||||
приёме; `ETag` сравним только внутри одного часа — горизонт измерения входит в
|
||||
метку и едет вместе с часами.
|
||||
@@ -0,0 +1,111 @@
|
||||
# read-api Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
Общие правила читающих маршрутов — то, что у каталога, точек, тренировок,
|
||||
записей, статистики и MCP одинаково и потому не должно решаться каждым заново.
|
||||
Форма конкретного ответа принадлежит capability самого маршрута; здесь — правила
|
||||
поверх них.
|
||||
|
||||
Первое и главное: **публичный контракт объявляет транспорт.** Форма ответа не
|
||||
выводится из формы доменных типов, поэтому её смена есть правка транспортного
|
||||
слоя — действие, а не побочный эффект переименования поля в домене. Цена
|
||||
названа и она обратная: новое поле домена в ответ само не попадёт.
|
||||
|
||||
## Requirements
|
||||
### Requirement: Публичный контракт читающего маршрута меняется только правкой транспорта
|
||||
|
||||
Система SHALL объявлять форму ответа каждого читающего маршрута типами
|
||||
транспортного слоя и MUST NOT выводить её из формы доменных типов: ни один тип
|
||||
домена MUST NOT достигать сериализации ответа, а перевод домена в форму провода
|
||||
MUST быть явным перечислением полей.
|
||||
|
||||
**Читающий маршрут** здесь — тот, что отдаёт наружу состояние витрины: каталог,
|
||||
точки, тренировки, записи, статистика. `/healthz` под правило не подпадает —
|
||||
его тело есть литеральный признак живости процесса, а не данные.
|
||||
|
||||
**MCP собственной формы провода не объявляет.** Адаптер переводит вызовы в те
|
||||
же обработчики (`docs/architecture.md`, раздел «MCP»), поэтому объявленная форма
|
||||
у контракта одна на оба транспорта. Второе объявление развело бы их молча.
|
||||
|
||||
Правило распространяется и на **тело отказа**: у читающего маршрута оно часть
|
||||
того же контракта, и клиент видит его чаще успешного ответа. Тело отказа MUST
|
||||
собираться объявленным типом транспорта, а не картой и не свободной строкой.
|
||||
|
||||
**Следствие, ради которого правило и существует:** переименование поля
|
||||
доменного типа, разъединение встроенной в него структуры и появление в нём
|
||||
нового поля байты ответа не меняют — до сериализации доменный тип не доезжает.
|
||||
Смена публичного контракта становится правкой транспортного слоя, то есть
|
||||
действием, а не побочным эффектом.
|
||||
|
||||
**Цена названа и взята сознательно, потому что она обратная.** Новое поле
|
||||
домена не попадает в ответ само: чтобы клиент его увидел, транспорт обязан его
|
||||
перечислить. Контракт перестаёт меняться случайно в обе стороны, и это дороже
|
||||
ровно на объём перевода.
|
||||
|
||||
Из правила есть одно исключение, и оно ограничено **содержимым**, а не
|
||||
конвертом: значение, которое хранилище держит дословно, уезжает клиенту сырым
|
||||
JSON без разбора и переобъявления — переписывать его в тип провода значило бы
|
||||
нарушить инвариант «точки хранятся дословно». Конверт вокруг такого значения —
|
||||
метки времени, офсет, единицы, слой — объявляется типом провода и нормализуется,
|
||||
как того требует инвариант «форма Apple не транслируется».
|
||||
|
||||
#### Scenario: Поле доменного типа переименовано
|
||||
|
||||
- **GIVEN** поле доменного типа, из которого собирается ответ, переименовано, а
|
||||
транспортный слой не тронут
|
||||
- **WHEN** маршрут отвечает на прежний запрос при прежнем состоянии витрины
|
||||
- **THEN** байты ответа те же
|
||||
|
||||
#### Scenario: Форма провода изменена намеренно
|
||||
|
||||
- **GIVEN** транспорт переименовал поле объявленной формы
|
||||
- **WHEN** маршрут отвечает на прежний запрос при прежнем состоянии витрины
|
||||
- **THEN** байты ответа изменились, и изменение целиком лежит в правке
|
||||
транспортного слоя
|
||||
|
||||
#### Scenario: Читающий маршрут отвечает отказом
|
||||
|
||||
- **WHEN** читающий маршрут отвечает кодом отказа
|
||||
- **THEN** тело отказа собрано объявленным типом транспорта
|
||||
|
||||
#### Scenario: Словарь значения виден клиенту строкой
|
||||
|
||||
- **GIVEN** доменное перечисление, чьи значения клиент видит строками
|
||||
- **WHEN** маршрут отвечает
|
||||
- **THEN** строку в ответ кладёт транспорт, а домен собственной сериализации не
|
||||
несёт
|
||||
|
||||
#### Scenario: Дословное содержимое проходит насквозь
|
||||
|
||||
- **WHEN** в ответ идёт значение, сохранённое хранилищем дословно
|
||||
- **THEN** оно уезжает сырым JSON, а конверт вокруг него объявлен типом провода
|
||||
|
||||
### Requirement: Пустая коллекция чтения — список, а не отсутствие
|
||||
|
||||
Система SHALL отдавать пустую коллекцию читающего маршрута как `[]` и MUST NOT
|
||||
отдавать её как `null`; отсутствующее значение MUST отдаваться как `null` и
|
||||
MUST NOT подменяться нулевым значением своего типа.
|
||||
|
||||
Правило общее для всех читающих маршрутов, потому что цена у него одна:
|
||||
`nil`-срез сериализуется в `null`, и клиент читает «поля нет» там, где на самом
|
||||
деле «элементов нет»; правдоподобная нулевая дата в ответе неотличима от
|
||||
настоящей. Конкретные коллекции и поля каждого маршрута нормирует его
|
||||
собственная capability — здесь только правило, чтобы следующий маршрут не
|
||||
принимал его заново.
|
||||
|
||||
Ручной перевод домена в форму провода делает это правило не теоретическим:
|
||||
присваивание поле в поле копирует `nil` и разыменовывает указатель ровно так,
|
||||
как написано, и обе ошибки молчат.
|
||||
|
||||
#### Scenario: Коллекция ответа пуста
|
||||
|
||||
- **WHEN** в ответ читающего маршрута идёт коллекция без единого элемента
|
||||
- **THEN** она уезжает как `[]`
|
||||
|
||||
#### Scenario: Значения нет
|
||||
|
||||
- **WHEN** поле ответа не имеет значения
|
||||
- **THEN** оно уезжает как `null`, а не как нулевая метка времени, пустая
|
||||
строка или ноль
|
||||
|
||||
+16
-1
@@ -102,7 +102,22 @@ def main() -> int:
|
||||
step("build", ["go", "build", "./..."], "не собирается")
|
||||
step("vet", ["go", "vet", "./..."])
|
||||
if shutil.which("golangci-lint"):
|
||||
step("lint", ["golangci-lint", "run"])
|
||||
# Кеш линтера — СВОЙ на дерево, а не общий на машину.
|
||||
#
|
||||
# Общий `~/.cache/golangci-lint` отдаёт замечания, привязанные к
|
||||
# путям ЧУЖОГО worktree того же модуля: конвейер задач работает в
|
||||
# `tmp/wt-*`, и прогон в одном дереве красил соседнее сообщением про
|
||||
# файл, которого в нём нет (воспроизведено дважды за одну задачу,
|
||||
# оба раза `../…/internal/store/store.go: time.Now forbidden` — а
|
||||
# исключение `^internal/(ident|store)/` на такой путь не
|
||||
# распространяется). Краснота по причине, не связанной с
|
||||
# изменением, приучает не читать красноту — та же цена, что у
|
||||
# записи 2026-08-04 в docs/review.md.
|
||||
step(
|
||||
"lint",
|
||||
["golangci-lint", "run"],
|
||||
env={"GOLANGCI_LINT_CACHE": str((OUT_DIR / "golangci").resolve())},
|
||||
)
|
||||
else:
|
||||
record(SKIP, "lint", "golangci-lint не установлен (task setup)")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user