Files
houseplan-card/docs/reviews/SPEC-REVIEW-530-r1.md
T
2026-09-11 09:46:23 +00:00

22 KiB
Raw Blame History

SPEC-REVIEW-530-r1

Issue: #530 — «Экспорт в PDF: план занимает половину листа A4, а столбец выносов отбирает у него целый шаг масштаба» Этап: spec (ревью ТЗ, PROCESS.md §2.4) Трек: полный (автор называет нарушенные критерии §5: «нет влияния на производительность» и «нет нового UX-контракта» — оба нарушены обоснованно, см. «Что проверено и корректно») Заход: r1 · блокирующих циклов израсходовано 0 из 4

Скоуп

ТЗ живёт в теле issue #530, раздел ## ТЗ (после аналитики S2 и текста «ТЗ готово» в комментариях). Предмет: убрать столбец «Internal dimensions» и метки R… из PDF-экспорта плана (src/pdf/pdf-scene.ts), уменьшить кегли подписей внутри плана на 25 % (размер 7→5.25, площадь 8→6, имя комнаты 9/7→6.75/5.25), не трогая ряд масштабов и правило выбора ориентации. Контракт К1-К5, AC1-AC6.

Как проверялось

Чтением кода, без исполнения — на этапе ТЗ кода ещё нет, гейты не прогонялись (правка кода не входит в этот этап). Каждое количественное или поведенческое утверждение ТЗ сверено построчно с текущим dev на материале 8f5a08a6 (совпадает с SHA, на котором получена аналитика S2).

  • docs/SCOPE.md — продуктовая рамка (J4/J6, персона «администратор дома», печатный лист как артефакт, уносимый из продукта); раздел «Out of scope» с узким исключением для #53 (сам факт существования PDF-экспорта).
  • AGENTS.md, PROCESS.md §2.3-§2.5, §5, §7.1 — формальные требования к ТЗ и лёгкому/полному треку.
  • docs/PDF-EXPORT.md — канонический документ подсистемы PDF-экспорта (не входит в список §-канонов AGENTS.md дословно, но это единственный документ, детально описывающий поведение, которое правка меняет).
  • docs/USER-GUIDE.ru.md:1835-1857 — раздел «Сохранение текущего пространства в PDF»; ссылается на PDF-EXPORT.md за деталями, сам текст выносок/столбца не упоминает.
  • src/pdf/pdf-scene.ts целиком (840 строк): PDF_SCALE_SERIES, betterPdfCandidate, buildPdfPage, цикл подбора масштаба (770-839), блок печати меток/столбца (624-696), кегли (584-740).
  • src/i18n/{en,ru,fr,de}.json:452 — pdf.internal_dimensions существует во всех четырёх локалях.
  • test/pdf-scene.test.mjs целиком (840 строк): тесты unprintable fixed callouts fail closed… (494-503), oversized architecture brackets a larger printable scale… (505-514), dense non-rectangular rooms keep mandatory dimensions in stable callouts (563-598), бюджетный тест на 200 мс CPU (618-679).
  • demo/golden/matrix.mjs:367-419 — сценарии pdf-export-geometry-light, pdf-export-stepped-dimensions-light, их текущие minSceneCoverage.
  • scripts/mutation-gate.mjs:224-247 — существующие мутанты pdf-dimensions-ignore-toggle, pdf-room-edge-dropped, их guard/because.

Находки

Medium — правка меняет два поведения, задокументированных в docs/PDF-EXPORT.md, но документ не назван ни в скоупе, ни в release-артефактах

Файл: тело issue #530, разделы «Чего задача не трогает» и «Release-артефакты».

docs/PDF-EXPORT.md — единственный текст, детально описывающий именно то поведение, которое ТЗ меняет, и он расходится с новым контрактом в двух конкретных местах:

  1. Строка 59: «…while required values that cannot fit beside an edge use numbered callouts» — после AC1 это утверждение становится ложным: такое значение теперь не печатается вовсе (контракт п.1: «Размер, который не удалось разместить внутри комнаты, не печатается»), а не переносится в столбец, которого больше нет. Заодно строка 32 перечисляет «callouts» как часть собираемой сцены — тоже устаревает.
  2. Строки 41-42: «If a fixed annotation itself cannot fit on one A4 sheet, export stops with an error instead of producing a clipped file.» Прочитан код, который это реализует: buildPdfPage (src/pdf/pdf-scene.ts:770-839) подбирает масштаб перебором PDF_SCALE_SERIES, а при неудаче — экспоненциальным пробегом до maxFallbackScale = 1_000_000 (773-820), и бросает pdf.failed только если вообще ни один масштаб не подошёл (821) либо финальная проверка границ не сошлась (838). Единственный тест, дёргающий этот путь — unprintable fixed callouts fail closed… (494-503) — форсирует его руками через name: 'X'.repeat(1000), которое превращается в строку столбца выносов (${callout.mark} ${callout.room}: ${callout.value}, 691) фиксированной ширины, не сжимающуюся с ростом масштаба. Архитектура сама по себе всегда находит подходящий масштаб (второй тест, 505-514, явно это проверяет: output.scale > 500, до 1:1 000 000), а подписи внутри плана (имя 9/7, размер 7, площадь 8) при нехватке места не бросают исключение, а тихо не печатаются (nameSize = 0, комментарий 674-676 «Rectangular rooms have no unambiguous numbered callout fallback… omit the unsafe label»). То есть столбец выносов — единственный известный код-путь, форсирующий отказ pdf.failed через «не влезающую фиксированную подпись»; когда он уходит, предложение из PDF-EXPORT.md перестаёт описывать что-либо достижимое обычным использованием (кроме отдельного, уже названного в контракте п.5 случая «архитектура не влезает ни в один масштаб», #53) — но сам текст документа остаётся как есть и вводит в заблуждение.

Контракт п.5 ТЗ сам по себе корректен и честен («Отказ fail closed сохраняется для архитектуры, которая не влезает ни в один масштаб (#53)») — это именно то более узкое, правильное описание, которое должно заменить нынешний текст PDF-EXPORT.md. Но раздел «Release-артефакты» называет только два бюллетеня changelog; раздел «Чего задача не трогает» тоже не упоминает документацию. DoR (PROCESS.md §2.5) требует «release-артефакты названы: changelog RU+EN, документация, golden/скриншоты… — либо явное "нет"» — здесь пункт «документация» просто отсутствует, а не назван явным «нет», и это не безобидный пропуск: документ буквально описывает то, что задача удаляет.

Требуется от автора (правка ТЗ, тот же issue, без нового): добавить docs/PDF-EXPORT.md в затронутые файлы/release-артефакты и явно зафиксировать, что строки 32/58-59 (упоминание «callouts»/«numbered callouts») убираются, а строки 41-42 (fail-closed для «fixed annotation») переформулируются в духе контракта п.5 — только для архитектуры, не влезающей ни в один масштаб.

Серьёзность — Medium, в скоупе задачи: без High-находок это жёлтый вердикт, правка ТЗ и повторный (второй) заход.

Low — существующий мутант pdf-room-edge-dropped защищает ровно ту гарантию, которую контракт отменяет

Файл: scripts/mutation-gate.mjs:235-247.

Мутант pdf-room-edge-dropped (because: 'every non-short edge must retain a direct value or a numbered callout') роняет из массива дедуплицированных рёбер последнее (.slice(0, -1)) и ожидает, что тест dense non-rectangular rooms keep mandatory dimensions in stable callouts его поймает. AC1 переписывает именно этот тест на противоположное утверждение («не печатается» вместо «восстанавливается через столбец»), и «every non-short edge must retain a direct value or a numbered callout» после правки более не является инвариантом продукта. AC-таблица называет только два новых мутанта (pdf-restores-dimension-callouts, pdf-keeps-large-in-plan-labels), не называя, что делать со старым pdf-room-edge-dropped — хотя scripts/mutation-gate.mjs уже назван затронутой поверхностью в аналитике S2. Не блокирует: файл уже в списке затронутых, и это естественное следствие переписывания теста, на который мутант ссылается по имени — но стоит явно решить (снять мутант или переформулировать его because) при реализации, а не молча оставить ссылку на несуществующую больше гарантию.

Что проверено и корректно

  • Продуктовая рамка (§7.1). Раздел «Продуктовая рамка» называет персону (администратор дома, docs/SCOPE.md), поверхность (печатный лист) и момент («уносит из продукта наружу»). «До/После» сформулированы одной фразой без терминов реализации.
  • Трек выбран верно. Оба названных нарушенных критерия §5 подтверждаются: перф — сборка сцены имеет собственный бюджет 200 мс, проверяемый существующим тестом (618-679), состав сцены меняется; UX-контракт — печатный лист меняет ориентацию (книжная вместо альбомной на форме владельца) и теряет целый раздел (столбец), это не «поведение в рамках уже описанного».
  • Все количественные утверждения о текущем состоянии подтверждены чтением кода. PDF_SCALE_SERIES = [20,25,50,75,100,150,200,250,500] (pdf-dimensions.ts:572); кегли — размер 7 (pdf-scene.ts:611,643), площадь 8 (596), имя 9/7 (584-585), метка R… 6 (673), заголовок столбца 8 (688), строка столбца 6 (692); хром страницы — заголовок 14 (707), название карточки 10 (710), «Scale 1:N» 8 (716), линейка/компас 7 (728,736,740) — контракт п.2 correctно утверждает, что хром не меняется. Арифметика ×0.75 (7→5.25, 8→6, 9→6.75, 7→5.25) верна.
  • AC1/К1 точно описывают, что удаляется. Столбец (688-693), линия-выноска и счётчик R${callouts.length+1} (670-673), ключ pdf.internal_dimensions — существует во всех четырёх локалях (en/ru/fr/de.json:452) и больше нигде в src/**, кроме pdf-scene.ts:688 и самого теста (test/pdf-scene.test.mjs:587).
  • Два переписываемых теста названы точно и по существу. unprintable fixed callouts fail closed instead of returning a clipped PDF page (494-503) действительно форсирует переполнение именно через столбец (см. находку выше) — «теряет предмет» после удаления столбца заявлено честно, не втихую. dense non-rectangular rooms keep mandatory dimensions in stable callouts (563-598) действительно утверждает восстановимость через pdf.internal_dimensions/R\d+ — ТЗ верно называет, что смысл теста меняется на противоположный.
  • AC5 использует существующие, а не выдуманные golden-сценарии. pdf-export-geometry-light (matrix.mjs:371) и pdf-export-stepped-dimensions-light (415) существуют, у обоих уже задан pdfSemantic.minSceneCoverage (0.20 и 0.15), который run.mjs:292-293 сравнивает с фактическим покрытием — «поднятый до фактического» порог технически корректен как способ доказательства.
  • Контракт п.1 «правило, по которому уже сегодня живут прямоугольные комнаты, становится общим» — подтверждено. Для прямоугольных комнат (nonRect === false) код уже тихо пропускает неразмещённую подпись без исключения (комментарий 674-676); правка лишь распространяет это же поведение на непрямоугольные.
  • AC4 корректно закрывает риск «потеряли подписи, убирая столбец». Формулировка требует не меньшего числа подписей на плане (38 vs 35) и отсутствия повторной печати значения — это тот же класс требования, что и «одно число — один источник»; отдельного нарушения этого принципа в диффе нет, поскольку до правки значение печаталось либо на плане, либо в столбце, никогда в обоих местах одновременно, и после правки источник остаётся один — на плане либо нигде.
  • Риски честны и по существу. Кегль 5.25 pt (~1.85 мм) назван на грани чертёжной практики с обозначенным путём отступления (−15 % вместо −25 %); потеря семи коротких размеров явно вынесена в changelog; необходимость пересъёмки golden на Linux CI (#455) названа как риск, а не как гейт «для галочки».
  • Блок «Принято предположительно» сформулирован по форме, требуемой §7.1 («принято предположительно, менять свободно»), и содержит только техническую механику (пилообразное заполнение — отдельная задача; поля/шапка/подвал не трогаются; выноска у стены — альтернатива, не эта задача), без продуктовых решений, спрятанных под видом технических.
  • Откат — один revert, данные/конфиг/публичные контракты не затронуты — соответствует реальному объёму правки (i18n-ключ + один модуль рендера + тесты + golden-кадры).

Чего не проверял

  • Не запускал npx tsc --noEmit, npm test, npm run build, golden — этап ТЗ, продуктового кода ещё нет, оценивать нечего.
  • Не проверял точность самих числовых замеров аналитики S2 (35→38 подписей, 29 против 36 уникальных значений, доли площади листа) — не переисполнял buildPdfPage на синтетической фикстуре владельца; это воспроизводимый прогон Node-скрипта, а не факт из документа, и на этапе ТЗ отдельно не перепроверялся. Внутренняя согласованность утверждений (аркметика кеглей, существующие константы, имена тестов) проверена полностью и разошлась только в находках выше.
  • Не проверял demo/smoke_pdf_export.mjs на предмет ассертов, зависящих от столбца/меток — файл назван затронутой поверхностью в S2, но правка теста туда не входит в контракт ТЗ явно; беглый grep не нашёл в нём упоминаний internal_dimensions/R\d/callout, так что видимого зазора нет, но фикстуры смока не читал построчно.
  • Не проверял docs/images/10-pdf-export.png (скриншот диалога опций) на предмет устаревания — диалог опций (чекбоксы) визуально не меняется этой задачей, только содержимое самого PDF, так что скриншот UI, по всей видимости, не затронут; глубже не копал.

Вердикт

Жёлтый. Контракт технически точен и хорошо обоснован для всего, что он утверждает о src/pdf/pdf-scene.ts и тестах (все количественные и поведенческие claims сверены с кодом построчно и подтвердились), выбор полного трека обоснован верно, продуктовая рамка и AC полны и проверяемы. Но задача меняет два конкретных предложения канонического документа docs/PDF-EXPORT.md (упоминание «numbered callouts» и формулировку fail-closed для «fixed annotation»), и ни «Чего задача не трогает», ни «Release-артефакты» этот документ не называют — DoR §2.5 требует явного «документация: …» или явного «нет», здесь нет ни того, ни другого. Это Medium-находка в скоупе задачи, без High.


Вердикт: жёлтый · заход r1 · блокирующих циклов 1/4 · High: 0 · Medium: 1 → в задаче


Материал раунда

  • Ветка: dev, коммит 8f5a08a60974 — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
  • Дерево материала: 0728cc169939a5f201f20df07fc695b6055a071c
    git log --all --format='%H %T' | grep 0728cc169939
    
  • Тело issue: 3c6248e088a3948ba1b6a44fafbc15118b8ad7706edb941b92b3fd9a4c8135ac
  • Вердикт конвейера: yellow · High 0