mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 13:18:58 +00:00
251 lines
20 KiB
Markdown
251 lines
20 KiB
Markdown
# SPEC-REVIEW-405-r1
|
||
|
||
- Issue: https://github.com/Matysh/houseplan-card/issues/405
|
||
- ТЗ: `docs/specs/405-dropped-promise-and-witness-floor.md`
|
||
- SHA ТЗ: `cd0c85b9ad5096d4772f99edc0354dea6a62f241`
|
||
- Заход: r1 · блокирующих циклов израсходовано 0 из 4 (до этого раунда)
|
||
- Трек: полный (заявлен автором — две несвязанные поверхности, `src/**` и
|
||
`scripts/**`; критерий `small` «одна поверхность» не выполняется — согласен)
|
||
- Вердикт: **жёлтый**
|
||
|
||
## Скоуп ревью
|
||
|
||
Первый заход, дельты нет — разбор ТЗ целиком по §7.1: обязательные разделы,
|
||
однозначность и доказуемость AC1…AC8, соответствие описанных технических фактов
|
||
реальному коду (`src/houseplan-editor-runtime.ts`, `src/houseplan-card.ts`,
|
||
`src/editor-secondary.ts`, `demo/smoke_free_walls.mjs`,
|
||
`scripts/docs-acceptance.mjs`, `test/docs-acceptance.test.mjs`), а также
|
||
продуктовая рамка по `docs/SCOPE.md` (задача инфраструктурная по духу — не про
|
||
J1–J7, а про доказательность гейтов, что для класса B/тестовой инфраструктуры
|
||
уместно и не требует отдельного продуктового обоснования; правка `src/**`
|
||
делает задачу полным треком по §1, но не продуктовой в смысле пользовательского
|
||
сценария — ТЗ прямо пишет «видимого поведения не меняет», что не противоречит
|
||
скоупу, поскольку задача чинит доказательство существующего инварианта, а не
|
||
добавляет функциональность).
|
||
|
||
## Как проверялось
|
||
|
||
Читкой кода, без исполнения (spec review, гейты не прогонялись — это код-ревью
|
||
этап, здесь ему нечего проверять по §2.4). Для каждого технического утверждения
|
||
ТЗ — сверка со строками файлов на HEAD (`cd0c85b9`):
|
||
|
||
- `src/houseplan-editor-runtime.ts:3050-3150, 5282-5300` — таблица «Место» для
|
||
M2 (`_deletePhysicalSelection`, `_runEditorContext`, `_deleteDraftWhole`);
|
||
- `src/editor-secondary.ts:200-224` — `runContext`;
|
||
- `src/houseplan-card.ts:3015-3022, 7845-7874` — вызывающие и параллельные
|
||
обёртки `_deletePhysicalSelection`/`_deleteDraftWhole`/`_deleteDraftSegment`;
|
||
- `demo/smoke_free_walls.mjs:1-30, 175-208` — стаб `_confirmDanger` и вызовы
|
||
`_deletePhysicalSelection`;
|
||
- `scripts/docs-acceptance.mjs:1-128` — `docsWitnessFloor`,
|
||
`docsAcceptancePlan`, вычисление `floor`;
|
||
- `test/docs-acceptance.test.mjs` — существующие тесты AC7/AC8;
|
||
- `scripts/golden-acceptance.mjs:1-200`, `demo/golden/accept.mjs:1-121`,
|
||
`demo/golden/run.mjs:800-850` — сверка риска «изменение порога затрагивает
|
||
golden»;
|
||
- `scripts/mutation-gate.mjs` — конвенция именования мутантов (`id: 'kebab-case'`),
|
||
сверена с предложенными `draft-delete-drops-the-promise` и
|
||
`witness-floor-counts-survivors` — согласуется.
|
||
|
||
Один факт проверен эмпирически (не просто чтением): предположение, что
|
||
TypeScript пропустит функцию, аннотированную `(): void`, но фактически
|
||
возвращающую `Promise<void>` изнутри, — **проверено `tsc`** на изолированном
|
||
файле (см. находку M2-a ниже), потому что от этого зависела оценка серьёзности
|
||
находки, а не только её наличие.
|
||
|
||
Issue #406 (куда ТЗ отправляет один Low-пункт) прочитан целиком через
|
||
`gh issue view 406` — сверка ссылки «#406 «д»».
|
||
|
||
## Находки
|
||
|
||
### M2-a (Medium, в скоупе) — таблица «Место» неполна: пропущена обёртка `houseplan-card.ts:7856`, и без неё правка не пройдёт `tsc --noEmit`
|
||
|
||
Файл: `docs/specs/405-dropped-promise-and-witness-floor.md`, раздел «(1) M2»,
|
||
таблица «Место» и абзац «Практически».
|
||
|
||
**Что не так.** Смок вызывает не `houseplan-editor-runtime.ts`, а
|
||
`window.__card` — экземпляр класса `houseplan-card.ts` (`demo/srv/demo.html:194`
|
||
создаёт `<houseplan-card>`, `demo/serve.mjs` его же отдаёт в `window.__card`,
|
||
`smoke_free_walls.mjs:194,199` зовёт `c._deletePhysicalSelection()`). Реальная
|
||
точка входа — обёртка `src/houseplan-card.ts:7856`:
|
||
|
||
```ts
|
||
private _deletePhysicalSelection = (): void => {
|
||
return this._editorRuntimeOrThrow()._deletePhysicalSelection();
|
||
}
|
||
```
|
||
|
||
Таблица «Место» в ТЗ называет только `editor-secondary.ts:221`,
|
||
`houseplan-editor-runtime.ts:5290` и `:3073` — эту строку не называет вовсе.
|
||
«Практически» перечисляет три функции для правки (`_deletePhysicalSelection`
|
||
в runtime, `_runEditorContext`, `runContext`) — тоже без неё.
|
||
|
||
**Воспроизведено**: изолированный `tsc --strict --noEmit` на паре функций,
|
||
повторяющей форму `houseplan-card.ts:7856` после того, как внутренний вызов
|
||
меняет тип на `Promise<void>`, а внешняя обёртка остаётся `(): void`:
|
||
|
||
```ts
|
||
async function inner(): Promise<void> {}
|
||
const wrapper = (): void => {
|
||
return inner();
|
||
};
|
||
```
|
||
|
||
```
|
||
error TS2322: Type 'Promise<void>' is not assignable to type 'void'.
|
||
```
|
||
|
||
Это не эстетика типов — это реальный отказ гейта `npx tsc --noEmit`,
|
||
единственного среди трёх дешёвых гейтов, который правка данной задачи
|
||
действительно затрагивает. Если исполнитель проверит только смок (JS,
|
||
типов не видит) или отправит правку без прогона `tsc`, регресс поймает CI,
|
||
но раунд код-ревью на это тратиться не должен.
|
||
|
||
**Это не догадка о недостающем шаге, а видимый паттерн этого же файла.**
|
||
Строками ниже, `houseplan-card.ts:7864` и `:7868`, у соседних методов
|
||
(`_deleteDraftWhole`, `_deleteDraftSegment`) обёртки **уже** типизированы
|
||
`Promise<void>`, зеркаля асинхронные методы runtime. Это подтверждает: в файле
|
||
есть устоявшаяся конвенция «тип обёртки повторяет тип метода runtime», и
|
||
`_deletePhysicalSelection`-обёртка на `:7856` — единственная, которую эта
|
||
задача обязана привести к тому же виду, а ТЗ её не называет.
|
||
|
||
**Требуется**: добавить `src/houseplan-card.ts:7856` в таблицу «Место» и
|
||
в перечень правок «Практически» — тип обёртки меняется на `Promise<void>` с
|
||
`return`, как у `_deleteDraftWhole`/`_deleteDraftSegment` рядом.
|
||
|
||
Серьёзность — Medium, не High: контракт AC1–AC8 в остальном верен и
|
||
достижим, дефект — не в контракте, а в полноте технической карты внутри уже
|
||
названного в скоупе файла (`src/houseplan-card.ts` фигурирует в файлах задачи
|
||
неявно — это тот же модуль, где объявлена обёртка `_deleteDraftWhole`,
|
||
`_deleteDraftSegment`, уже упомянутых как соседний прецедент); дешёвый гейт
|
||
`tsc` его поймает при обычной проверке, но ТЗ, которое ведёт прямиком к
|
||
провалу первого же гейта, не «выполнимо без доработки» по мерке §2.4.
|
||
|
||
### M4-b (Low) — ссылка «#406 «д»» на несуществующий пункт
|
||
|
||
Файл: `docs/specs/405-dropped-promise-and-witness-floor.md`, раздел «Скоуп /
|
||
не-скоуп»:
|
||
|
||
> затирание `acceptance.declared` при повторной приёмке (#406 «д») — соседняя
|
||
> строка того же файла, но другой контракт
|
||
|
||
Прочитан issue #406 целиком (`gh issue view 406`): в нём ровно четыре пункта,
|
||
(а)–(г) — мёртвые ключи `confirm.*`, роль диалога, непокрытая HA-ветка
|
||
подтверждения, снапшоты area. Пункта «(д)» нет. Оригинальный аудит-комментарий
|
||
в теле #405 упоминает это как отдельный Low-пункт («Отдельно, из того же
|
||
разбора (Low, чинить попутно): повторная приёмка неизменённого набора
|
||
затирает `acceptance.declared`… `scripts/docs-accept.mjs:142-150`»), но нигде
|
||
не заводит его как пункт «(д)» и не кладёт в #406.
|
||
|
||
Итог: Low-находка из исходного аудита сейчас **не отслеживается ни одним
|
||
issue** — ссылка ТЗ на несуществующий пункт создаёт впечатление, что она
|
||
где-то учтена, хотя это не так.
|
||
|
||
**Решение ревьюера**: не правится сейчас (сам дефект `docs-accept.mjs:142-150`
|
||
за пределами скоупа #405 — другая строка того же файла, другой контракт, как
|
||
верно замечает автор) — но ссылка ложная и должна быть исправлена: либо
|
||
пункт действительно добавляется в #406, либо ТЗ отправляет его в новый issue,
|
||
либо ТЗ формулирует «Low, пока нигде не заведён» вместо несуществующей ссылки.
|
||
Это правится в рамках текущего цикла возврата (Low, не отдельный лимит).
|
||
|
||
## Медиум вне скоупа — заведён отдельным issue
|
||
|
||
### Golden-эталоны подвержены тому же классу дефекта, что и M4 — #408
|
||
|
||
`scripts/golden-acceptance.mjs:182-183` считает порог свидетелей от
|
||
`withBaseline.length` — числа сцен со статусом **не** `missing-baseline`, то
|
||
есть от того, что уцелело на диске, а не от размера известной матрицы
|
||
(`GOLDEN_SCENARIOS.length`). Проверено чтением всей цепочки
|
||
(`demo/golden/run.mjs:836-839` ставит `status: 'missing-baseline'`, когда файла
|
||
эталона нет; `goldenAcceptanceRefusal` в `scripts/golden-acceptance.mjs:59-93`
|
||
**требует**, чтобы каждая такая сцена была объявлена через `--expect-new` —
|
||
и не более; `demo/golden/accept.mjs:64-72` вызывает `goldenWitnessRefusal` уже
|
||
после этой проверки). Модельный разбор: `git rm demo/golden/baselines/*.png`
|
||
+ объявление всех сцен `--expect-new` → каждая сцена легально проходит
|
||
`goldenAcceptanceRefusal` → `withBaseline.length === 0` →
|
||
`goldenWitnessFloor(0) === 0` → `witnesses.length (0) < floor (0)` ложно →
|
||
отказа `goldenWitnessRefusal` нет. `--reviewed` не является независимой
|
||
проверкой (булев CLI-флаг), так что рассуждение ТЗ #405 о том, что M4
|
||
недостаточно прикрыт `--reviewed` для `docs-accept.mjs`, симметрично относится
|
||
и сюда.
|
||
|
||
ТЗ #405 обсуждает golden только в разделе «Риски» и только применительно к
|
||
формуле (`goldenWitnessFloor` та же формула, что `docsWitnessFloor`, и должна
|
||
остаться той же) — не к тому, что вызывающий код (`withBaseline.length`)
|
||
подвержен той же уязвимости на входе, что чинит M4. Файл другой
|
||
(`scripts/golden-acceptance.mjs`, не `scripts/docs-acceptance.mjs`), скоуп
|
||
ТЗ его не называет — правка не входит в #405.
|
||
|
||
Issue заведён: **#408**, метки `bug`, `P2`, `S1-new`, `infra`, ссылка на #405.
|
||
|
||
## Проверено и корректно
|
||
|
||
- Обязательные разделы §7.1 присутствуют все: сценарий, что человек увидит,
|
||
проблема, скоуп/не-скоуп, контракт, UX, модель данных/миграция, i18n, AC1–AC8
|
||
с доказательством, план автотестов, риски, откат, release-артефакты.
|
||
- Продуктовых вопросов владельцу нет, догадок, выданных за факт, в контракте
|
||
M2/M4 не найдено (кроме описанного выше пробела в таблице «Место» — это не
|
||
догадка, а неполнота карты).
|
||
- Таблица «Место» для M2 (кроме отсутствующей строки `houseplan-card.ts:7856`)
|
||
точно соответствует коду: `runContext` (`editor-secondary.ts:221`),
|
||
`_runEditorContext` (`houseplan-editor-runtime.ts:5290`),
|
||
`_deletePhysicalSelection` (`:3073`, ветка `sel.kind === 'draft'` действительно
|
||
роняет промис через `return`), `_deleteDraftWhole` (`:3119`, действительно
|
||
`async`, `await _confirmDanger`).
|
||
- Смок `demo/smoke_free_walls.mjs:16` — стаб `_confirmDanger = async () => true`
|
||
резолвится в том же микротаске, и `:194,199` действительно не проверяет
|
||
ожидание по-настоящему; воспроизведение из тела issue (макрозадача → два
|
||
`FAILED`) правдоподобно и согласуется с кодом.
|
||
- `scripts/docs-acceptance.mjs:44-46,107-108` — формула и место вычисления
|
||
`floor` от `withCommitted.length` подтверждены; контраст «PNG на месте» vs
|
||
«PNG удалены» из тела issue воспроизводим по чтению кода без исполнения.
|
||
- Предложенный контракт AC6 (`floor` от размера набора сценариев, не от
|
||
наличия файлов) корректно сохраняет существующее поведение AC7 — проверено
|
||
арифметически: `docsWitnessFloor(ids.length)` и текущее
|
||
`docsWitnessFloor(withCommitted.length)` дают одно и то же значение во всех
|
||
ветках существующих тестов `test/docs-acceptance.test.mjs`, включая
|
||
«кадр без закоммиченной пары не может быть свидетелем» (`floor` остаётся 1
|
||
в обоих случаях — 3 или 1 округляются к одному порогу на этом наборе).
|
||
- «Не-скоуп» по `_deleteDraftSegment` — проверено: у него отдельный,
|
||
корректно типизированный путь (`houseplan-card.ts:7868` уже `Promise<void>`,
|
||
зовётся напрямую из своей кнопки, не через `_deletePhysicalSelection`) —
|
||
исключение обосновано верно, не прячет тот же дефект.
|
||
- Риск «изменение порога затрагивает golden» правильно определяет, что *формулу*
|
||
трогать нельзя без зеркалирования — но не замечает независимый дефект на
|
||
стороне вызова (см. #408).
|
||
- AC2 (отрицательный прогон обязателен) сформулирован по правилу «тест должен
|
||
уметь падать» и предлагает конкретный механизм (`scripts/mutation-gate.mjs`,
|
||
который в проекте уже используется по этому шаблону) — не декларация.
|
||
- Откат и release-артефакты соразмерны факту «видимого поведения нет»
|
||
(`User-Visible: no` подтверждён отсутствием изменений в UX/i18n разделах).
|
||
|
||
## Чего не проверял
|
||
|
||
- Гейты (`tsc`, `npm test`, `npm run build`, смоки, `golden:verify`,
|
||
`model-invariants`) не прогонял — этап spec review, кода задачи ещё нет,
|
||
прогонять нечего. Эмпирическая проверка ограничена одним изолированным
|
||
TS-файлом вне репозитория, подтверждающим находку M2-a (см. выше), — это не
|
||
прогон проектных гейтов.
|
||
- Не проверял `scripts/mutation-gate.mjs` на предмет того, заведутся ли
|
||
предложенные мутанты технически без ошибок раннера — это вопрос реализации,
|
||
а не ТЗ; на этапе спеки достаточно, что названный механизм существует и
|
||
используется в проекте по тому же образцу.
|
||
- Не проверял golden `test/golden-policy.test.mjs` целиком — находка по #408
|
||
установлена чтением `scripts/golden-acceptance.mjs` и `demo/golden/accept.mjs`
|
||
напрямую; тестовое покрытие этой ветки (принимает ли текущий набор тестов
|
||
сценарий «все эталоны удалены») не сверял — это будет частью работы над #408,
|
||
не #405.
|
||
- Не оценивал вне двух Medium/одного Low другие возможные дефекты в
|
||
`scripts/docs-accept.mjs` целиком (не-`docs-acceptance.mjs`) — за пределами
|
||
того, что уже упомянуто как «не-скоуп» самим ТЗ.
|
||
|
||
## Итог
|
||
|
||
Один Medium **в скоупе** (M2-a) не даёт зелёный вердикт по §2.4 — ТЗ ведёт
|
||
прямиком к отказу гейта `tsc --noEmit`, если исполнитель буквально следует
|
||
таблице «Место». Плюс один Low (ложная ссылка на #406) для правки в этом же
|
||
цикле. Medium вне скоупа заведён отдельным issue (#408), в задаче #405 не
|
||
чинится.
|
||
|
||
**Вердикт: жёлтый.** Возврат автору: дополнить таблицу «Место» и раздел
|
||
«Практически» строкой `src/houseplan-card.ts:7856`, поправить ссылку на #406.
|