mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 05:08:53 +00:00
@@ -0,0 +1,258 @@
|
||||
# CODE-REVIEW — issue #454 · заход r3 (см. «Заход vs факт» ниже)
|
||||
|
||||
- Issue: https://github.com/Matysh/houseplan-card/issues/454
|
||||
- Материал: `git rev-parse HEAD` = `4b443d8c` (ветка `issue/454-review-round-counter`,
|
||||
ранее реализация была на `634478a5`; конвейер перед ревью привёл её к `dev`
|
||||
через ребейз (+12 коммитов `dev`, включая `05ef3181`, `aa51ed2b`, `8935a736`,
|
||||
`169fed01`, `4203a01b`, `2202b207` и др.) — после ребейза это другой код
|
||||
(PROCESS.md §7.2), поэтому разбор ниже **полный**, не по дельте.
|
||||
- `git diff origin/dev...HEAD --stat`: 8 файлов, +1226/−3 —
|
||||
`.github/workflows/process.yml`, `scripts/review-doc-guard.mjs`,
|
||||
`scripts/mutation-gate.mjs`, `test/review-doc-guard.test.mjs`,
|
||||
`docs/specs/454-review-round-counter.md`, `docs/specs/README.md`,
|
||||
`docs/reviews/SPEC-REVIEW-454-r1.md`, `-r2.md`.
|
||||
- Класс изменения: B (инфраструктура), `User-Visible: no` на всех коммитах —
|
||||
проверено `git show -s --format=%B` на каждом из 7 коммитов диапазона.
|
||||
|
||||
## Заход vs факт (важно для чтения этого документа)
|
||||
|
||||
Заголовок задачи называет это «заход r3». Проверено по таймлайну меток
|
||||
(`gh api repos/.../issues/454/timeline`): метка `S7-code-review` навешена
|
||||
на #454 **ровно один раз**, в 16:57:56. Это первый и единственный код-ревью
|
||||
раунд #454 — фактически он `r1`. Номер `r3` в имени документа — не ошибка
|
||||
приложенного шаблона, а живое проявление ровно того дефекта, который #454
|
||||
чинит: см. **M1** ниже, где это воспроизведено вплоть до реального лога guard
|
||||
этого самого прогона. Оставляю нумерацию файла как задаст шаг публикации
|
||||
(`CODE-REVIEW-454-r3.md`) — переименовывать его вручную значит повторить
|
||||
ту же ошибку «не доверять факту конвейера», от которой и уходит #454.
|
||||
Раздела «Унаследовано из r<N−1>»/«Закрытие раунда r<N−1>» в этом документе
|
||||
нет: предыдущего код-ревью раунда #454 не существовало.
|
||||
|
||||
## Скоуп
|
||||
|
||||
`process.yml`: шаг `guard`/`decide` больше не считает `attempt`/`spent`
|
||||
целиком по прозе комментариев issue — рядом встаёт независимый счёт по
|
||||
опубликованным `docs/reviews/${marker}-${NUM}-r*.md` (через новый мелкий
|
||||
checkout `ref: dev` + `gh api contents`). Логика вынесена в чистые функции
|
||||
`scripts/review-doc-guard.mjs` (`reviewRoundsFromFiles`, `attemptFromRounds`,
|
||||
`verdictDeclaration`, `blockingFromDocs`, `reviewCounters`, CLI `--counters`),
|
||||
покрыта `test/review-doc-guard.test.mjs`, три новых мутанта в
|
||||
`scripts/mutation-gate.mjs`. `docs/specs/README.md` — строка индекса.
|
||||
Продуктовый код (`src/**`), i18n, бандл, touch — не задеты.
|
||||
|
||||
## Как проверялось — гейты
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| Typecheck | `npx tsc --noEmit` | чисто, без вывода |
|
||||
| Unit-тесты | `npm test` | 1936 pass / 0 fail / 1 skip |
|
||||
| Build | `npm run build` | сборка прошла (`rollup -c`), см. отдельное наблюдение о фингерпринте ниже — не относится к диффу #454 |
|
||||
| Mutation gate | `node scripts/mutation-gate.mjs --check` | `EXIT=0`, все мутанты (включая три новых `review-round-*`) поймали своё |
|
||||
| check-docs | `node scripts/check-docs.mjs` | passed (7 files, 12 links) — не обязателен для этого диффа (`src/**` не тронут), прогнан для полноты |
|
||||
|
||||
Не гоняю: `golden:verify`, `smoke_*.mjs`, `pytest tests_backend`, performance —
|
||||
диапазон не касается рендера, геометрии, бэкенда или perf-путей. Инварианты
|
||||
модели (`npm run invariants`) не нужны — ни ребро, ни `layout`, ни
|
||||
`marker.space` не затронуты.
|
||||
|
||||
## Находки
|
||||
|
||||
### M1 (Medium, в скоупе задачи) — страховка по комментариям может завысить и `attempt`, и `spent` через посторонний маркер в прозе; заявление «перерасчёт невозможен по построению» не выполняется на реальных данных
|
||||
|
||||
**Воспроизведено исполнением на боевых данных #454, не на синтетике.**
|
||||
|
||||
Guard этого самого прогона (job `101105199249`, run `33898021033`,
|
||||
`gh api repos/Matysh/houseplan-card/actions/jobs/101105199249/logs`)
|
||||
напечатал:
|
||||
|
||||
```
|
||||
этап code, заход 3, блокирующих циклов 0 из 4
|
||||
```
|
||||
|
||||
при том, что по таймлайну меток это первый код-ревью раунд #454 (должно быть
|
||||
`заход 1`). Причина — старая (пока не заменённая, `process.yml` живёт на
|
||||
`main`, куда #454 ещё не смёржен) страховка по комментариям:
|
||||
|
||||
```sh
|
||||
of_stage="[.comments[] | select(.body | test(\"Вердикт:\")) | select(.body | test(\"CODE-REVIEW\"))]"
|
||||
```
|
||||
|
||||
Прогон дословно этим выражением на реальных комментариях #454
|
||||
(`gh issue view 454 --json comments`, `jq`) даёт `of_stage.length = 2`:
|
||||
|
||||
- `#issuecomment-5543499255` (16:29:05) — зелёный вердикт **SPEC-REVIEW** r2.
|
||||
Матчит `Вердикт:` (свой) и `CODE-REVIEW` — потому что текст вердикта
|
||||
разбирает историю #449 и содержит фразу «маркер `CODE-REVIEW` в нём есть,
|
||||
но в прозе». Это не отсылка к собственному файлу, а цитата чужого маркера
|
||||
внутри анализа самой этой задачи.
|
||||
- `#issuecomment-5543835259` (16:57:54) — комментарий «Реализация готова»,
|
||||
вообще не вердикт. Матчит `Вердикт:` — потому что в тексте есть фраза
|
||||
«строки `Вердикт:` в нём нет» (описание AC2 в обратных кавычках), и
|
||||
`CODE-REVIEW` — потому что в тексте разбираются найденные баги на
|
||||
`CODE-REVIEW-441-r1.md`/`CODE-REVIEW-230-r2.md`/«CODE-REVIEW 439».
|
||||
|
||||
Оба совпадения — ложные: ни один из двух комментариев не является
|
||||
код-ревью-вердиктом #454. `attempt = 2 + 1 = 3`, `spent = 0` (ни один из двух
|
||||
не «жёлтый/красный» в своей строке) — совпадает с логом guard buкв в букву.
|
||||
|
||||
**Тот же результат получен НОВЫМ кодом, который и рецензируется**, не только
|
||||
старым shell (проверено исполнением на `HEAD`):
|
||||
|
||||
```
|
||||
$ node -e 'import("./scripts/review-doc-guard.mjs").then(m => console.log(
|
||||
m.reviewCounters({ rounds: [], docs: [], comments: { attempt: 3, spent: 0 } })))'
|
||||
{
|
||||
attempt: 3, spent: 0,
|
||||
attemptFiles: 1, spentFiles: 0,
|
||||
attemptComments: 3, spentComments: 0,
|
||||
blocking: [], unread: []
|
||||
}
|
||||
```
|
||||
|
||||
`rounds: []` — потому что ни одного `CODE-REVIEW-454-r*.md` ещё не
|
||||
опубликовано (проверено `git ls-tree -r HEAD --name-only | grep 454` — есть
|
||||
только два `SPEC-REVIEW-454-*`). Файловая половина эту задачу считает
|
||||
абсолютно верно (`attemptFiles=1`). Ломает её `Math.max` со второй, не
|
||||
переписываемой этим ТЗ половиной.
|
||||
|
||||
**Почему это не «синтетический край», а найденный вживую пример класса #89.**
|
||||
ТЗ и AC5 формулируют защиту так: «Вердикт чужого этапа не влияет на счёт
|
||||
(#89 не сломан)» — без оговорки. По факту это верно только для
|
||||
`reviewRoundsFromFiles`/`attemptFromRounds` (доказано юнитом
|
||||
«чужой этап и чужая задача в счёт не идут», `test/review-doc-guard.test.mjs:300`)
|
||||
и НЕ верно для итогового `reviewCounters`, потому что `attemptComments`
|
||||
считается прежним, недоказанным способом и участвует в `max()` наравне с
|
||||
надёжным источником. В параграфе §5 самого ТЗ это фактически признано
|
||||
оговоркой «по файлам» («Правило… по файлам оно выполняется строго»), но
|
||||
таблица AC5 эту оговорку не несёт, а раздел §3 идёт дальше и заявляет
|
||||
«перерасчёт невозможен по построению» — это неверно в общем виде: невозможно
|
||||
удвоение счёта (сумма против максимума), но не невозможно завышение одной из
|
||||
компонент максимума, и именно это сейчас произошло.
|
||||
|
||||
**Чем краснеет сильнее, чем в этом раунде.** В этом прогоне `spentComments`
|
||||
совпал с `spentFiles` (оба 0) только потому, что заражающий SPEC-REVIEW-вердикт
|
||||
был зелёным. Формула `blocking` смотрит на цвет строки вердикта самого
|
||||
комментария, а не на то, кому вердикт принадлежит: будь заражающий комментарий
|
||||
жёлтым/красным вердиктом СВОЕГО (не CODE-REVIEW) этапа, `spentComments` вырос
|
||||
бы, `max(spentFiles, spentComments)` унаследовал бы это, и код-ревью потерял
|
||||
бы цикл бюджета §4 раньше, чем реально стартовал, — тот самый вред, ради
|
||||
которого заведено #89 и который AC5 обещает не допустить.
|
||||
|
||||
**Практический эффект сегодня** — не потеря артефакта (`docs/reviews/` пуст
|
||||
для `CODE-REVIEW-454-*`, коллизии нет) и не потеря бюджета (`spent` верен),
|
||||
а искажённое имя файла: шаг публикации назовёт этот документ
|
||||
`CODE-REVIEW-454-r3.md`, оставив `r1`/`r2` навсегда пропущенными для этой
|
||||
задачи. Не деструктивно, но прямо противоречит зафиксированному в ТЗ «После:
|
||||
каждый заход получает собственное имя файла» — здесь заход получает чужое.
|
||||
|
||||
**Почему Medium, а не High.** Первичный вред, ради которого заведено #454 —
|
||||
уничтожение предыдущего артефакта ревью (#220/#365-класс) — этим случаем не
|
||||
демонстрируется: коллизии имени нет, `spent` не искажён. Демонстрируется
|
||||
недоказанность одного конкретного защитного заявления (AC5/§3) в комбинации
|
||||
источников, а не поломка первичной гарантии. Это чинится в текущей задаче
|
||||
(в скоупе — счёт раундов ровно то, чем #454 занимается), не отдельным issue.
|
||||
|
||||
**Предлагаемое направление (не мандат, выбор за автором):**
|
||||
1. Ужесточить страховку по комментариям: матчить не голый `CODE-REVIEW`, а
|
||||
что-то вроде `CODE-REVIEW-<NUM>-r` — привязка к номеру issue резко снижает
|
||||
шанс случайного совпадения в прозе, оставаясь той же «прозой», от которой
|
||||
ТЗ явно отказывается полагаться как на основной источник, но убирает
|
||||
наиболее грубый класс ложных срабатываний из резервного источника;
|
||||
и/или
|
||||
2. Добавить видимость расхождения: `::warning::`, когда
|
||||
`attemptComments > attemptFiles + 1` без объясняющего файла — сегодня
|
||||
расхождение тихое, лог `guard` печатает только итоговое число;
|
||||
и/или
|
||||
3. Смягчить формулировку «перерасчёт невозможен по построению» в
|
||||
`docs/specs/454-review-round-counter.md` §3 и в комментарии `process.yml`
|
||||
до того, что реально доказано («не даёт двойного счёта одного раунда»,
|
||||
а не «не может завысить»), и закрепить тестом текущее (несовершенное)
|
||||
поведение вместо документирования отсутствующей гарантии.
|
||||
|
||||
Любое из трёх снимает находку; я не настаиваю на конкретной реализации.
|
||||
|
||||
## Наблюдения — не находки, не блокируют
|
||||
|
||||
- **Бандл `custom_components/houseplan/frontend/houseplan-card.js` уже устарел
|
||||
относительно фингерпринта исходников `dev`** (проверено: свежий
|
||||
`npm run build` даёт другой `__HOUSEPLAN_BUILD_FINGERPRINT__`, чем
|
||||
закоммиченный). Причина найдена: коммит `28a4cb5e` (issue #455, уже в
|
||||
`dev`) поменял `package.json` — входит в `BUILD_INPUTS`
|
||||
`scripts/source-fingerprint.mjs` — без последующего `bundle:sync`. К #454
|
||||
отношения не имеет (диф #454 не трогает `src/**`/`package.json`/`dist/**`),
|
||||
предсуществует на `dev` независимо от этой задачи, и штатный механизм
|
||||
(`rollup.config.mjs`: несовпавший фингерпринт обязан «fail closed» перед
|
||||
golden/perf-съёмкой) рассчитан ровно на такой разрыв. Не завожу отдельный
|
||||
issue: не деструктивно, самоисправится ближайшим `bundle:sync` перед любой
|
||||
задачей, трогающей рендер/perf. Владельцу стоит знать на случай, если
|
||||
ближайшая golden-задача упрётся в неожиданный отказ.
|
||||
- `docs/specs/README.md`: строка `#454` вставлена перед `#440`/`#447` —
|
||||
таблица в остальном идёт по возрастанию номера issue. Косметика, не правлю
|
||||
находкой.
|
||||
|
||||
## AC — таблица доказательств
|
||||
|
||||
| AC | Критерий | Доказано | Комментарий |
|
||||
|---|---|---|---|
|
||||
| AC1 | `attempt` = max(номер)+1, не count+1 | unit, чтением | `test/review-doc-guard.test.mjs:288` — дыра `[1,3]→4` проверена |
|
||||
| AC2 | «#449 как есть» → `attempt=3, spent=1` | unit на слепке реальных данных | перепроверено мной независимым `gh` снимком #449 — числа совпадают |
|
||||
| AC2b | Реконструкция r1/r2/r3 → `attempt=4, spent=2` | unit | пройден |
|
||||
| AC3 | Имя файла никогда не повторяется | unit + мутант `review-round-counts-files-not-max` | мутант краснеет корректно (проверено `--check`) |
|
||||
| AC4 | Зелёный вердикт цикл не тратит | unit | `test/review-doc-guard.test.mjs` «зелёный вердикт цикла не тратит» |
|
||||
| AC5 | Вердикт чужого этапа не влияет на счёт (#89) | unit — **только для `reviewRoundsFromFiles`** | **см. M1**: не доказано для `reviewCounters` в целом; комментарийная половина не покрыта тестом на это утверждение и на реальных данных нарушается |
|
||||
| AC6 | Отказ публикации не занижает счёт | unit + мутант `review-round-drops-comment-insurance` | пройден |
|
||||
| AC7 | Нет ветки/API недоступен → поведение как раньше | unit | пройден, включая «мусор на входе» |
|
||||
| AC8 | `process.yml` идентичен в `main` и `dev` | существующий шаг Validate | сейчас идентичны (проверено `diff`); автор откладывает зеркалирование в `main` до после мержа — обоснованно, гейт сравнивает ветки между собой и покраснел бы раньше времени |
|
||||
| AC9 | Логика в тестируемом модуле, не в inline-shell | unit + чтение | `test/review-doc-guard.test.mjs` проверяет сам текст шага `decide` на вызов `node scripts/review-doc-guard.mjs --counters` |
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- Файловая половина счёта (`reviewRoundsFromFiles`, `attemptFromRounds`,
|
||||
`verdictDeclaration`, `blockingFromDocs`) — корректна, включая защиту от
|
||||
цитирования чужого вердикта («описание чужого раунда не объявляет вердикт»,
|
||||
коммит `4b443d8c`, добавлен по итогам находки самим автором на корпусе 629
|
||||
документов) и от блока внутри тройных кавычек не по месту.
|
||||
- Три новых мутанта реально ловятся штатным раннером (`--check`, exit 0).
|
||||
- Трейлеры всех 7 коммитов диапазона: `Issue: #454`, `User-Visible: no` —
|
||||
корректно для класса B, changelog не требуется.
|
||||
- `process.yml` `main`↔`dev` совпадают сегодня; план зеркалирования после
|
||||
мержа обоснован и не является дефектом.
|
||||
- Гейты (typecheck/test/build/mutation/check-docs) зелёные на `HEAD`.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- `golden:verify`, `smoke_*.mjs`, `pytest tests_backend`, performance-профили,
|
||||
инварианты модели — diff их не касается.
|
||||
- Реальный прогон `guard` С НОВЫМ кодом на живом GitHub Actions (сам workflow
|
||||
ещё не в `main`/`dev`) — логика проверена юнитами и прямым вызовом
|
||||
`reviewCounters`/`--counters` на данных, снятых с реального API, но не в
|
||||
контексте самого `job` целиком (шаг разрешения ветки через
|
||||
`git ls-remote`/`gh api matching-refs`, постраничный лимит 1000 файлов) —
|
||||
эта часть осталась shell-кодом без юнитов по прямому требованию ТЗ
|
||||
(«guard остаётся лёгким», §4 ТЗ) и проверена только чтением.
|
||||
- Не пересчитывал вручную мутацию `review-round-drops-file-source` построчно
|
||||
за пределами штатного раннера — доверился `--check`.
|
||||
|
||||
## Вердикт
|
||||
|
||||
Один Medium в скоупе, без High. Возврат автору тем же issue (#454), не
|
||||
отдельный issue.
|
||||
|
||||
Документ: `docs/reviews/CODE-REVIEW-454-r3.md` (публикуется шагом конвейера;
|
||||
см. раздел «Заход vs факт» насчёт номера).
|
||||
|
||||
---
|
||||
|
||||
<!-- material-anchors: сгенерировано конвейером (#414) -->
|
||||
|
||||
## Материал раунда
|
||||
|
||||
- Ветка: `issue/454-review-round-counter`, коммит `634478a51077` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
|
||||
- Дерево материала: `f8467cb113cd3c09cf3a10235bb7034ee9121063`
|
||||
```
|
||||
git log --all --format='%H %T' | grep f8467cb113cd
|
||||
```
|
||||
- ТЗ `docs/specs/454-review-round-counter.md`, блоб `b5af292101a495e4699d839397e837a3a2467c85`
|
||||
```
|
||||
git log --all --find-object=b5af292101a495e4699d839397e837a3a2467c85 -- docs/specs/454-review-round-counter.md
|
||||
```
|
||||
Reference in New Issue
Block a user