diff --git a/.github/workflows/process.yml b/.github/workflows/process.yml index 6ef9056e..6d2287c3 100644 --- a/.github/workflows/process.yml +++ b/.github/workflows/process.yml @@ -405,6 +405,8 @@ jobs: if: steps.rebase.outputs.conflict != 'true' env: NUM: ${{ github.event.issue.number }} + REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} run: | echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" # Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита @@ -422,6 +424,24 @@ jobs: specs=$(git ls-files -s -- "docs/specs/${NUM}-*.md" \ | awk '{print $2" "$4}' | tr '\n' ';') echo "specs=$specs" >> "$GITHUB_OUTPUT" + # Тело issue — материал ревью ТЗ (#517): с переходом на ТЗ в теле это + # единственный якорь, доказывающий «вердикт вынесен на этом тексте». + # Читается здесь, а не из github.event.issue.body: между событием + # метки и вызовом модели проходят минуты (ребейз, гейт #510, ожидание + # Validate), и снимок события описывал бы не тот текст. + body=$(mktemp) + if gh issue view "$NUM" --repo "$REPO" --json body --jq .body > "$body"; then + digest=$(node -e ' + import("./scripts/review-doc-guard.mjs").then(async (m) => { + const { readFileSync } = await import("node:fs"); + process.stdout.write(m.issueBodyDigest(readFileSync(process.argv[1], "utf8"))); + }); + ' "$body") + echo "issue_body=$digest" >> "$GITHUB_OUTPUT" + echo "тело issue: ${digest:0:12}" + else + echo "::warning::тело issue $NUM не прочитано — якорь ТЗ в документ не попадёт" + fi echo "материал ревью: $(git rev-parse --short HEAD), дерево $(git rev-parse --short 'HEAD^{tree}')" # Повторное применение зелёного вердикта без вызова модели (#499). Сценарий @@ -438,8 +458,13 @@ jobs: if: steps.rebase.outputs.conflict != 'true' && needs.guard.outputs.stage == 'code' env: NUM: ${{ github.event.issue.number }} + # Правка ТЗ между раундами обязана отменять повторное применение + # зелёного вердикта: иначе вызов модели пропускается и находку + # «ТЗ менялось» некому напечатать (#517 AC6). + ISSUE_BODY: ${{ steps.material.outputs.issue_body }} run: | - out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD) + out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD \ + --issue-body="${ISSUE_BODY}") printf '%s\n' "$out" printf '%s\n' "$out" >> "$GITHUB_OUTPUT" if printf '%s\n' "$out" | grep -qx 'reuse=true'; then @@ -622,6 +647,40 @@ jobs: "$bin" --version echo "path=$bin" >> "$GITHUB_OUTPUT" + # ТЗ живёт в теле issue (#517), а GitHub хранит его правки без diff и без + # доступной чтением истории. Единственное, что можно доказать, — «текст + # изменился после того, как ревью ТЗ признало его зелёным». Это не отказ: + # автор вправе уточнять ТЗ. Это находка, доставляемая ревьюеру кода в + # промпт и владельцу в комментарий вердикта: разбор AC в таком заходе + # обязан быть полным, а не по дельте. + - name: "ТЗ менялось после зелёного ревью ТЗ (#517)" + id: spec_body + if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' && needs.guard.outputs.stage == 'code' + env: + NUM: ${{ github.event.issue.number }} + BRANCH: ${{ steps.branch.outputs.name }} + DIGEST: ${{ steps.material.outputs.issue_body }} + run: | + if [ -z "$DIGEST" ]; then + echo "хеша тела нет — сравнивать не с чем" + exit 0 + fi + out=$(node -e ' + import("./scripts/review-doc-guard.mjs").then(async (m) => { + const { execFileSync } = await import("node:child_process"); + const [num, digest] = process.argv.slice(1); + const git = (args) => { try { return execFileSync("git", args, { encoding: "utf8" }); } catch { return ""; } }; + const names = git(["ls-tree", "--name-only", "HEAD:docs/reviews"]).split("\n") + .filter((name) => new RegExp(`^SPEC-REVIEW-${num}-r\\d+\\.md$`).test(name)); + const docs = names.map((name) => ({ name, text: git(["show", `HEAD:docs/reviews/${name}`]) })); + const changed = m.issueBodyChanged(docs, digest); + if (changed) process.stdout.write(`changed=true\ndoc=${changed.doc}\nrecorded=${changed.recorded}\n`); + else process.stdout.write("changed=false\n"); + }); + ' "$NUM" "$DIGEST") + printf '%s\n' "$out" + printf '%s\n' "$out" >> "$GITHUB_OUTPUT" + - name: Review id: review if: steps.gate.outputs.proceed == 'true' && steps.reuse.outputs.reuse != 'true' @@ -651,6 +710,8 @@ jobs: ${{ steps.rebase.outputs.note }} + ${{ steps.spec_body.outputs.changed == 'true' && format('ТЗ в теле issue менялось после зелёного ревью ТЗ ({0}, записанный хеш {1}). GitHub хранит правки тела без diff — дельту показать нельзя, поэтому AC сверяются с ТЕКУЩИМ текстом целиком, а не по дельте, и находка называется в вердикте (#517).', steps.spec_body.outputs.doc, steps.spec_body.outputs.recorded) || '' }} + **Если цикл не первый — объём разбора по дельте, а не заново** (PROCESS.md §2.9, issue #214). Раньше промпт был одинаковым для всех раундов, и повторный цикл заново выводил продуктовую рамку и @@ -696,8 +757,9 @@ jobs: LIGHT.md, CANVAS.md, WALL-THICKNESS.md, UX-MODES.md, CONFIG-COMPATIBILITY.md, TOUCH-SUPPORT.md. - Для этапа spec: если issue помечен small, ТЗ живёт в теле issue и - файла в docs/specs/ быть не должно. Иначе ТЗ — docs/specs/-*.md. + Для этапа spec: ТЗ живёт в теле issue (#517) — читай его, а не файл. + Файлы docs/specs/-*.md — архив ТЗ до 2026-09-10: если такой файл + есть у старой задачи, он и есть материал, новые не создаются. Проверь обязательные разделы §7.1, однозначность каждого AC и указание способа доказательства. Отдельно проверь, что автор не выдал догадку за решение: утверждение о поведении, которого нет ни @@ -860,6 +922,7 @@ jobs: MATERIAL_SHA: ${{ steps.material.outputs.sha }} MATERIAL_TREE: ${{ steps.material.outputs.tree }} MATERIAL_SPECS: ${{ steps.material.outputs.specs }} + MATERIAL_ISSUE_BODY: ${{ steps.material.outputs.issue_body }} # Вердикт из structured_output попадает в блок якорей (#499): по нему # следующий заход решает, можно ли применить зелёный вердикт повторно. OUT: ${{ steps.review.outputs.structured_output }} @@ -921,6 +984,7 @@ jobs: node scripts/review-doc-guard.mjs --anchor="$doc" \ --sha="$MATERIAL_SHA" --tree="$MATERIAL_TREE" \ --branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS" \ + --issue-body="$MATERIAL_ISSUE_BODY" \ --verdict="$verdict" --high="$high" else echo "::warning::$SOURCE не найден — документа для публикации нет" diff --git a/AGENTS.md b/AGENTS.md index 6434a3cb..35268350 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -305,10 +305,14 @@ does not meet. ## Specs -`docs/specs/-.md`, linked to its issue in both directions. Required -sections are in `PROCESS.md` §7.1, plus two product ones: which persona meets this, -on which surface, at what moment; and what the person sees before and after, in one -sentence without implementation terms. +The spec lives in the **issue body**, under a `## ТЗ` heading (owner decision +2026-09-10, #517); `docs/specs/` is an archive of specs written before that date +and takes no new files. Required sections are in `PROCESS.md` §7.1, plus two +product ones: which persona meets this, on which surface, at what moment; and what +the person sees before and after, in one sentence without implementation terms. +Proof that a verdict was passed on a given text is the pipeline's job: it writes +the `sha256` of the normalised body into the review document's anchor block, and +an edit made after a green spec review reaches the code reviewer as a finding. **Ambiguity is asked, not guessed — but only product ambiguity.** A guess written as fact is the worst kind of defect: it passes review because it looks like a decision. diff --git a/PROCESS.md b/PROCESS.md index a3f9d8a1..6429c612 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -122,9 +122,12 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready → ### 2.3 ТЗ в работе — написание ТЗ - **Кто:** автор ТЗ, назначает себя. Статус означает «занято». -- **Артефакт:** `docs/specs/-.md`, где `NN` — **номер issue**. - Многоэтапная задача: `--stage.md`. -- **Лёгкий трек:** ТЗ пишется в теле issue, файл не создаётся (§5). +- **Артефакт:** **тело issue**, раздел `## ТЗ` (решение владельца 2026-09-10, + #517). Файл в `docs/specs/` не создаётся ни на одном треке: каталог — архив + ТЗ до этой даты, и задачи, у которых файл уже есть, доживают по старой схеме. + Доказуемость («вердикт вынесен на этом тексте») держит конвейер: в блок якорей + документа ревью пишется `sha256` нормализованного тела, и правка ТЗ после + зелёного ревью ТЗ приходит ревьюеру кода находкой, а не тишиной. - **Выход:** полная первая редакция по §7. ### 2.4 ТЗ на ревью @@ -155,8 +158,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready → - миграция и compatibility-поля решены по `docs/CONFIG-COMPATIBILITY.md`; - влияние на производительность и бюджеты названо (или явно «нет»); - влияние на touch по `docs/TOUCH-SUPPORT.md` (View и киоск — блокирующие); -- release-артефакты по правилу `docs/specs/README.md` (changelog RU+EN, - документация, golden/скриншоты, performance/security); +- release-артефакты названы: changelog RU+EN, документация, golden/скриншоты, + performance/security — либо явное «нет»; - **откат**: как выключить или вернуть назад (флаг Labs, обратная миграция); - открытых продуктовых вопросов нет; риски перечислены. @@ -428,8 +431,8 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready → **Что упрощается:** -- ТЗ пишется **в теле issue** по шаблону: проблема · контракт · AC1…ACn с - доказательством · откат. Файл в `docs/specs/` не создаётся; +- ТЗ короче: проблема · контракт · AC1…ACn с доказательством · откат + (в теле issue, как и на полном треке с 2026-09-10); - ревью ТЗ — комментарий второго агента, отдельный документ не нужен; - лимит ревью ТЗ — 2 цикла. @@ -488,7 +491,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready → | Роль | Делает | Не имеет права | |---|---|---| | Аналитик | разбор, оценки, поверхности | окончательно ставить приоритет | -| Автор ТЗ | `docs/specs/NN-*.md` или ТЗ в issue | ревьюить своё ТЗ | +| Автор ТЗ | раздел `## ТЗ` в теле issue | ревьюить своё ТЗ | | Ревьюер ТЗ | `docs/reviews/SPEC-REVIEW-NN-rN.md` | править ТЗ вместо автора | | Разработчик | код, автотесты, документация, changelog | ревьюить свой код, принимать golden | | Ревьюер кода | `docs/reviews/CODE-REVIEW-*-rN.md`, проверка AC | править продуктовый код | @@ -520,7 +523,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready → ``` issue #NN - ↔ ТЗ docs/specs/NN-slug.md (или тело issue при `small`) + ↔ ТЗ тело issue, раздел `## ТЗ` (хеш тела — в якорях ревью) ↔ ревью ТЗ docs/reviews/SPEC-REVIEW-NN-rN.md (или комментарий при `small`) ↔ ветка issue/NN-slug ↔ коммиты трейлеры Issue: #NN · User-Visible: yes|no @@ -593,10 +596,9 @@ issue #NN ### 7.3 Расхождения с текущим состоянием, которые надо закрыть -1. **Статус ТЗ дублирует статус issue.** `docs/specs/README.md` держит колонку - «Статус ТЗ» со своим словарём («черновик решения», «в реализации», - «реализовано»). Два источника статуса уже расходятся. Колонку убрать, оставить - таблицу «issue ↔ ТЗ». +1. ✅ **Статус ТЗ дублировал статус issue.** Закрыто 2026-09-10 (#517): ТЗ живёт + в теле issue, `docs/specs/` — архив, индекс с колонкой «Статус ТЗ» удалён + вместе с самой таблицей. Статус задачи — только метка `S*`. 2. **Ревью до релиза 1.62 живут вне репозитория.** Документы `CODE-REVIEW-*.md` и `SPEC-REVIEW-*.md` за прежний период лежат в папке владельца, и переносить их задним числом смысла нет: они описывают код, которого уже нет. Новые документы @@ -820,9 +822,11 @@ Performance зелёные на точном SHA, плюс зелёный E2E н 1. трейлер `Issue: #NN` у каждого коммита класса A/B, допускается несколько; 2. имя ветки `issue/NN-slug` соответствует трейлерам; -3. для класса A существует `docs/specs/NN-*.md` — **или** issue помечен `small`. - Офлайн это предупреждение: лёгкий трек держит ТЗ в теле issue, и без чтения - меток «ТЗ в issue» неотличимо от «ТЗ не написано». С `--issues` — отказ; +3. у класса A есть ТЗ: раздел `## ТЗ` или хотя бы один `AC1` в теле issue — + либо архивный `docs/specs/NN-*.md` у задачи до 2026-09-10. Офлайн тела нет, + и проверка молчит; с `--issues` — предупреждение (настоящий рубеж — ревью ТЗ). + Добавление нового файла в `docs/specs/**` тоже предупреждение: каталог + заморожен (#517); 4. `User-Visible: yes` → правки в обоих changelog в том же коммите; 5. коммит только класса D невалиден без `Release: vX.Y.Z` либо `Baseline-Reviewed: <ссылка на прогон CI>`; @@ -1110,9 +1114,9 @@ Golden, браузерные смоки, performance и полный HA-харн 1. ✅ **Метки созданы, бэклог размечен.** У всех открытых issue владельца ровно одна `S*`-метка, инварианты чистые. -2. ⏳ **Колонку «Статус ТЗ» из `docs/specs/README.md` убрать** — не сделано, §7.3 - п.1. Перенос старых документов ревью в `docs/reviews/` отменён: они описывают - код, которого уже нет. +2. ✅ **Колонка «Статус ТЗ» убрана** — вместе со всем индексом: `docs/specs/` + стал архивом, ТЗ переехало в тело issue (#517, 2026-09-10). Перенос старых + документов ревью в `docs/reviews/` отменён: они описывают код, которого уже нет. 3. ✅ **Гейт написан** — `scripts/process-gate.mjs` плюс job в `validate.yml`, issue #105. Прошёл **вне** флоу как инфраструктурная задача (§1, issue #118), а не через ТЗ и ревью, как предполагала прежняя редакция этого пункта. diff --git a/docs/specs/README.md b/docs/specs/README.md index 36b8c67b..0dafe305 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -1,17 +1,12 @@ -# Спецификации задач +# Спецификации задач — архив -Актуально на 2026-09-09. +**ТЗ живут в теле issue** (решение владельца 2026-09-10, [#517](https://github.com/Matysh/houseplan-card/issues/517)): раздел `## ТЗ` в самом issue, файл здесь больше не создаётся. Каталог сохраняет ТЗ, написанные до этой даты; задачи, у которых файл уже есть, доживают по старой схеме, и их файлы редактируются свободно. -Единственный канонический backlog проекта — GitHub Issues, а статус задачи — -её метка `S1-new`…`S8-merged` (`PROCESS.md` §9; GitHub Projects v2 не -используется с 2026-08-14, #139). Этот каталог содержит развёрнутые ТЗ полного -трека: каждое ТЗ ссылается на issue, а issue — на соответствующий файл. На лёгком -треке `small` (путь по умолчанию, #338) ТЗ живёт в теле issue и файла здесь не -имеет. Статус, приоритет и факт завершения меняются только в GitHub — словесные -пометки в файлах ТЗ («готово к реализации», «реализовано») справочные и статусом -не являются. +Почему переехало: файл решал ровно одну задачу — доказать, что вердикт ревью вынесен на конкретном тексте, — и создавал две. Индекс в этом README конфликтовал между параллельными задачами и служил вторым, отстающим словарём статусов; каждая правка ТЗ стоила коммита, пуша и метки. Доказуемость теперь обеспечивает конвейер: в блок якорей документа ревью пишется `sha256` нормализованного тела issue, а правка ТЗ после зелёного ревью ТЗ приходит ревьюеру кода находкой (`PROCESS.md` §2.3, §7.1). -## Обязательные release-артефакты номерного ТЗ +Единственный канонический backlog проекта — GitHub Issues; статус задачи — её метка `S1-new`…`S8-merged` (`PROCESS.md` §9). Индекс «issue ↔ ТЗ» здесь не ведётся: имя файла архива начинается с номера issue, этого достаточно, чтобы найти его командой `ls docs/specs/-*`. + +## Обязательные release-артефакты ТЗ Если задача меняет пользовательское поведение, её ТЗ обязано явно перечислить: @@ -20,224 +15,4 @@ - требуемые screenshots/golden и способ их review, если меняется визуал; - release/performance/security artifacts, если они входят в acceptance gate. -Отсутствие этого раздела не означает, что документация необязательна. Для -чистого refactoring ТЗ должно прямо зафиксировать отсутствие пользовательских -изменений и перечислить технические доказательства безопасного поведения. - -## P1 - -| Issue | ТЗ | -|---|---| -| [#506](https://github.com/Matysh/houseplan-card/issues/506) Запуск карточки без повторного layout из-за lazy summary runtime | [506-startup-performance.md](506-startup-performance.md) | -| [#510](https://github.com/Matysh/houseplan-card/issues/510) Мутанты по диффу только на кандидате ревью; ревью ждёт зелёный Validate; правила хендоффа | [510-mutants-on-candidate-and-review-waits-validate.md](510-mutants-on-candidate-and-review-waits-validate.md) | -| [#514](https://github.com/Matysh/houseplan-card/issues/514) E2E на реальном HA как гейт стабильного релиза | [514-e2e-stable-release-gate.md](514-e2e-stable-release-gate.md) | -| [#512](https://github.com/Matysh/houseplan-card/issues/512) Текст версии через seam вне golden-кадров; `docs:accept --identical` | [512-golden-version-seam-and-docs-identical-accept.md](512-golden-version-seam-and-docs-identical-accept.md) | -| [#498](https://github.com/Matysh/houseplan-card/issues/498) Backend hardening: точная квота upload, ключи палитры в support-пакете, предел цепочки ссылок SVG | [498-backend-hardening-quota-palette-svg-refs.md](498-backend-hardening-quota-palette-svg-refs.md) | -| [#495](https://github.com/Matysh/houseplan-card/issues/495) Результат Import согласован с commit; удаление маршрутов робота доходит до Store | [495-import-commit-and-route-runs-durability.md](495-import-commit-and-route-runs-durability.md) | -| [#492](https://github.com/Matysh/houseplan-card/issues/492) Точный кандидат интеграции и полный manifest входов selection/reuse | [492-exact-candidate-and-input-manifest.md](492-exact-candidate-and-input-manifest.md) | -| [#491](https://github.com/Matysh/houseplan-card/issues/491) Незавершённая пара Optimize/Undo переживает следующую запись | [491-optimize-undo-pair-recovery.md](491-optimize-undo-pair-recovery.md) | -| [#490](https://github.com/Matysh/houseplan-card/issues/490) Атомарный recovery и live-состояние сводной панели | [490-summary-recovery-live-state.md](490-summary-recovery-live-state.md) | -| [#486](https://github.com/Matysh/houseplan-card/issues/486) Панель House Plan в боковом меню HA | [486-house-plan-panel.md](486-house-plan-panel.md) | -| [#489](https://github.com/Matysh/houseplan-card/issues/489) Объявленный `data-hp`-контракт для UI и E2E | [489-data-hp-contract.md](489-data-hp-contract.md) | -| [#484](https://github.com/Matysh/houseplan-card/issues/484) Внешняя размерная цепь ступенчатого фасада в PDF | [484-pdf-exterior-dimension-chain.md](484-pdf-exterior-dimension-chain.md) | -| [#482](https://github.com/Matysh/houseplan-card/issues/482) Доводка экспорта пространства в PDF | [482-pdf-export-polish.md](482-pdf-export-polish.md) | -| [#471](https://github.com/Matysh/houseplan-card/issues/471) Убрать белые raised plates вокруг маркеров и названий комнат | [471-isometric-overlay-white-plates.md](471-isometric-overlay-white-plates.md) | -| [#6](https://github.com/Matysh/houseplan-card/issues/6) Vacuum XCME path segments | [006-vacuum-xcme-path.md](006-vacuum-xcme-path.md) | -| [#7](https://github.com/Matysh/houseplan-card/issues/7) Valetudo room outlines | [007-vacuum-valetudo-room-outlines.md](007-vacuum-valetudo-room-outlines.md) | -| [#8](https://github.com/Matysh/houseplan-card/issues/8) Vacuum support docs and XCME hint | [008-vacuum-support-docs-xcme-hint.md](008-vacuum-support-docs-xcme-hint.md) | -| [#27](https://github.com/Matysh/houseplan-card/issues/27) External vacuum source picker | [027-vacuum-external-source-picker.md](027-vacuum-external-source-picker.md) | -| [#28](https://github.com/Matysh/houseplan-card/issues/28) Room View card | [028-room-view-card.md](028-room-view-card.md) | -| [#29](https://github.com/Matysh/houseplan-card/issues/29) Device inbox lifecycle | [029-device-inbox-lifecycle.md](029-device-inbox-lifecycle.md) | -| [#30](https://github.com/Matysh/houseplan-card/issues/30) Dialog information architecture | [030-dialog-information-architecture.md](030-dialog-information-architecture.md) | -| [#31](https://github.com/Matysh/houseplan-card/issues/31) View accessibility | [031-view-accessibility.md](031-view-accessibility.md) | -| [#32](https://github.com/Matysh/houseplan-card/issues/32) Unified danger confirmation | [032-unified-danger-confirmation.md](032-unified-danger-confirmation.md) | -| [#33](https://github.com/Matysh/houseplan-card/issues/33) Config schema lifecycle | [033-config-schema-lifecycle.md](033-config-schema-lifecycle.md) | -| [#34](https://github.com/Matysh/houseplan-card/issues/34) Frontend decomposition | [034-frontend-decomposition.md](034-frontend-decomposition.md) | -| [#35](https://github.com/Matysh/houseplan-card/issues/35) Current UX documentation | [035-current-ux-docs.md](035-current-ux-docs.md) | -| [#50](https://github.com/Matysh/houseplan-card/issues/50) Экспорт и импорт конфигурации | [050-config-export-import.md](050-config-export-import.md) | -| [#58](https://github.com/Matysh/houseplan-card/issues/58) Vacuum integration coverage — Stage 1 | [058-vacuum-stage1.md](058-vacuum-stage1.md) | -| [#89](https://github.com/Matysh/houseplan-card/issues/89) Опциональный объёмный 2.5D/изометрический вид | [089-isometric-view.md](089-isometric-view.md) | -| [#89](https://github.com/Matysh/houseplan-card/issues/89) Этап 1: объёмный вид за флагом Labs | [089-isometric-view-stage1.md](089-isometric-view-stage1.md) | -| [#98](https://github.com/Matysh/houseplan-card/issues/98) Единая система пульсаций и активностей устройства | [098-device-pulse-system.md](098-device-pulse-system.md) | -| [#126](https://github.com/Matysh/houseplan-card/issues/126) HA Area переносит marker в новую комнату | [126-ha-area-marker-relocation.md](126-ha-area-marker-relocation.md) | -| [#131](https://github.com/Matysh/houseplan-card/issues/131) Полный первый кадр View у read-only-пользователя | [131-readonly-cold-start.md](131-readonly-cold-start.md) | -| [#138](https://github.com/Matysh/houseplan-card/issues/138) Автозамыкание комнаты по существующей стене | [138-adjacent-room-autoclose.md](138-adjacent-room-autoclose.md) | -| [#146](https://github.com/Matysh/houseplan-card/issues/146) Четырёхфазный фон «Следует за Солнцем» | [146-four-phase-sun-background.md](146-four-phase-sun-background.md) | -| [#156](https://github.com/Matysh/houseplan-card/issues/156) Регрессии Full Performance перед v1.64.0 stable | [156-full-performance-regressions.md](156-full-performance-regressions.md) | -| [#162](https://github.com/Matysh/houseplan-card/issues/162) Многоэтажный робот: карты отдельно от пространства базы | [162-vacuum-map-space-routing.md](162-vacuum-map-space-routing.md) | -| [#164](https://github.com/Matysh/houseplan-card/issues/164) Активный цикл стиральной машины должен быть жёлтым | [164-washer-active-cycle.md](164-washer-active-cycle.md) | -| [#166](https://github.com/Matysh/houseplan-card/issues/166) Солнечные лучи зеркально учитывают направление севера | [166-sun-north-rotation.md](166-sun-north-rotation.md) | -| [#167](https://github.com/Matysh/houseplan-card/issues/167) Экспорт «только планировка» | [167-plan-only-export.md](167-plan-only-export.md) | -| [#170](https://github.com/Matysh/houseplan-card/issues/170) HA-устройство не привязывается к комнате без HA-зоны | [170-room-without-area.md](170-room-without-area.md) | -| [#179](https://github.com/Matysh/houseplan-card/issues/179) Новый визуальный язык маркеров устройств | [179-device-icons-redesign.md](179-device-icons-redesign.md) | -| [#199](https://github.com/Matysh/houseplan-card/issues/199) Geometry preflight перед записью Optimize | [199-optimize-geometry-preflight.md](199-optimize-geometry-preflight.md) | -| [#210](https://github.com/Matysh/houseplan-card/issues/210) Фиксированное пространство экземпляра карточки | [210-fixed-floor-card.md](210-fixed-floor-card.md) | -| [#211](https://github.com/Matysh/houseplan-card/issues/211) Визуальное соответствие маркеров дизайн-пакету #179 | [211-device-icons-visual-parity.md](211-device-icons-visual-parity.md) | -| [#217](https://github.com/Matysh/houseplan-card/issues/217) Внешняя рамка Text-маркера должна быть капсулой | [217-text-shell-outline.md](217-text-shell-outline.md) | -| [#218](https://github.com/Matysh/houseplan-card/issues/218) Floating-point шум комнаты не гасит Glow пространства | [218-glow-floor-geometry.md](218-glow-floor-geometry.md) | -| [#219](https://github.com/Matysh/houseplan-card/issues/219) Единая палитра замков и glyph на оранжевых подложках | [219-lock-orange-palette.md](219-lock-orange-palette.md) | -| [#205](https://github.com/Matysh/houseplan-card/issues/205) Продолжение следа после короткой остановки пылесоса | [205-vacuum-trail-resume-grace.md](205-vacuum-trail-resume-grace.md) | -| [#220](https://github.com/Matysh/houseplan-card/issues/220) Порядок пространств перетаскиванием вкладок | [220-space-tab-reorder.md](220-space-tab-reorder.md) | -| [#229](https://github.com/Matysh/houseplan-card/issues/229) Сращивание коллинеарных отрезков стен | [229-merge-collinear-partitions.md](229-merge-collinear-partitions.md) | -| [#226](https://github.com/Matysh/houseplan-card/issues/226) Entity-marker не дублируется родительским HA-устройством | [226-entity-parent-dedup.md](226-entity-parent-dedup.md) | -| [#223](https://github.com/Matysh/houseplan-card/issues/223) Optimize канонизирует координаты без floating-point шума | [223-optimize-coordinate-canonicalization.md](223-optimize-coordinate-canonicalization.md) | -| [#224](https://github.com/Matysh/houseplan-card/issues/224) Канонические координаты на каждой записи | [224-config-coordinate-canonicalization.md](224-config-coordinate-canonicalization.md) | -| [#228](https://github.com/Matysh/houseplan-card/issues/228) Надёжное рисование стен и операции с готовым контуром | [228-plan-drawing-problems.md](228-plan-drawing-problems.md) | -| [#239](https://github.com/Matysh/houseplan-card/issues/239) Масштаб сетки не меняет внешний вид плана; default 1 см/1 дюйм | [239-grid-scale-invariance.md](239-grid-scale-invariance.md) | -| [#231](https://github.com/Matysh/houseplan-card/issues/231) Декоративный слой виден поверх заливок комнат | [231-decor-layer-order.md](231-decor-layer-order.md) | -| [#243](https://github.com/Matysh/houseplan-card/issues/243) Рабочее перетаскивание вкладок и точный указатель вставки | [243-space-tab-drop-target.md](243-space-tab-drop-target.md) | -| [#276](https://github.com/Matysh/houseplan-card/issues/276) Совпадающая partition становится одной общей стеной | [276-coincident-partition-reconciliation.md](276-coincident-partition-reconciliation.md) | -| [#277](https://github.com/Matysh/houseplan-card/issues/277) Безопасный Resize без изменения топологии | [277-safe-resize.md](277-safe-resize.md) | -| [#278](https://github.com/Matysh/houseplan-card/issues/278) Локальный сбой extra-body union не гасит всю кладку | [278-wall-union-isolation.md](278-wall-union-isolation.md) | -| [#279](https://github.com/Matysh/houseplan-card/issues/279) Сплошной почти ортогональный T-стык | [279-near-orthogonal-junction.md](279-near-orthogonal-junction.md) | -| [#280](https://github.com/Matysh/houseplan-card/issues/280) Backend принимает доказанный Optimize rehost | [280-optimize-rehost-validation.md](280-optimize-rehost-validation.md) | -| [#281](https://github.com/Matysh/houseplan-card/issues/281) Честный Resize после outer-partition reconciliation | [281-resize-zero-range.md](281-resize-zero-range.md) | -| [#293](https://github.com/Matysh/houseplan-card/issues/293) Активная рукоятка Resize выполняет pointer-жест | [293-resize-pointer-noop.md](293-resize-pointer-noop.md) | -| [#296](https://github.com/Matysh/houseplan-card/issues/296) Optimize удаляет доказанно избыточные скрытые стены | [296-optimize-hidden-obstacles.md](296-optimize-hidden-obstacles.md) | -| [#298](https://github.com/Matysh/houseplan-card/issues/298) Resize сохраняет wall records на решётке и на carrier | [298-resize-wall-thickness-carrier.md](298-resize-wall-thickness-carrier.md) | -| [#299](https://github.com/Matysh/houseplan-card/issues/299) Записи толщины не пересекают границу роли стены | [299-mixed-role-wall-records.md](299-mixed-role-wall-records.md) | -| [#314](https://github.com/Matysh/houseplan-card/issues/314) Атомарная запись v8 drafts и независимой геометрии | [314-v8-draft-write-regression.md](314-v8-draft-write-regression.md) | -| [#306](https://github.com/Matysh/houseplan-card/issues/306) Нулевые стены вместо виртуальных границ | [306-zero-thickness-walls.md](306-zero-thickness-walls.md) | -| [#348](https://github.com/Matysh/houseplan-card/issues/348) Полная немецкая локализация | [348-german-localization.md](348-german-localization.md) | -| [#428](https://github.com/Matysh/houseplan-card/issues/428) Round-trip экспорта с отсутствующей картинкой декора | [428-missing-decor-asset-roundtrip.md](428-missing-decor-asset-roundtrip.md) | -| [#462](https://github.com/Matysh/houseplan-card/issues/462) Надёжная регистрация frontend-ресурса и восстановление после обновления | [462-card-resource-registration.md](462-card-resource-registration.md) | - -## P2 - -| Issue | ТЗ | -|---|---| -| [#505](https://github.com/Matysh/houseplan-card/issues/505) Сводная панель: дизайнерский прототип и UX-исправления | [505-summary-panel-design-parity.md](505-summary-panel-design-parity.md) | -| [#493](https://github.com/Matysh/houseplan-card/issues/493) Ограниченный picker и надёжный lifecycle сводной панели | [493-summary-panel-hardening.md](493-summary-panel-hardening.md) | -| [#437](https://github.com/Matysh/houseplan-card/issues/437) Конфигурируемая сводная панель поверх плана | [437-summary-panel.md](437-summary-panel.md) | -| [#485](https://github.com/Matysh/houseplan-card/issues/485) Радары присутствия: общий контракт и три этапа | [Общий контракт](485-radar-presence.md) · [Этап 1](485-radar-presence-stage1.md) · [Этап 2](485-radar-presence-stage2.md) · [Этап 3](485-radar-presence-stage3.md) | -| [#487](https://github.com/Matysh/houseplan-card/issues/487) Пороги комфортной температуры для комнаты | [487-room-temperature-thresholds.md](487-room-temperature-thresholds.md) | -| [#478](https://github.com/Matysh/houseplan-card/issues/478) Отказ от persisted-сущности `room_drafts` | [478-remove-room-drafts.md](478-remove-room-drafts.md) | -| [#477](https://github.com/Matysh/houseplan-card/issues/477) Fixed point оптимизатора после штатного редактирования | [477-editor-writer-fixed-point.md](477-editor-writer-fixed-point.md) | -| [#10](https://github.com/Matysh/houseplan-card/issues/10) Roomba live position | [010-vacuum-roomba-live-position.md](010-vacuum-roomba-live-position.md) | -| [#442](https://github.com/Matysh/houseplan-card/issues/442) Атомарный откат отклонённых записей маркера | [442-marker-write-rollback.md](442-marker-write-rollback.md) | -| [#11](https://github.com/Matysh/houseplan-card/issues/11) Vacuum source health | [011-vacuum-source-health.md](011-vacuum-source-health.md) | -| [#12](https://github.com/Matysh/houseplan-card/issues/12) Room cleaning highlight | [012-vacuum-room-cleaning-highlight.md](012-vacuum-room-cleaning-highlight.md) | -| [#13](https://github.com/Matysh/houseplan-card/issues/13) Golden open context tray | [013-golden-open-context-tray.md](013-golden-open-context-tray.md) | -| [#19](https://github.com/Matysh/houseplan-card/issues/19) Additive Glow blending | [019-glow-additive-blending.md](019-glow-additive-blending.md) | -| [#20](https://github.com/Matysh/houseplan-card/issues/20) Glow through open doors | [020-glow-open-door-spill.md](020-glow-open-door-spill.md) | -| [#21](https://github.com/Matysh/houseplan-card/issues/21) Safe color CSS variables | [021-color-css-injection.md](021-color-css-injection.md) | -| [#36](https://github.com/Matysh/houseplan-card/issues/36) Room Glow override | [036-room-glow-override.md](036-room-glow-override.md) | -| [#37](https://github.com/Matysh/houseplan-card/issues/37) Room scale system | [037-room-scale-system.md](037-room-scale-system.md) | -| [#38](https://github.com/Matysh/houseplan-card/issues/38) Icon rule builder | [038-icon-rule-builder.md](038-icon-rule-builder.md) | -| [#39](https://github.com/Matysh/houseplan-card/issues/39) Large backdrops | [039-large-backdrops.md](039-large-backdrops.md) | -| [#40](https://github.com/Matysh/houseplan-card/issues/40) Floors/Areas onboarding | [040-floor-area-onboarding.md](040-floor-area-onboarding.md) | -| [#41](https://github.com/Matysh/houseplan-card/issues/41) Keyboard object editing | [041-keyboard-object-editing.md](041-keyboard-object-editing.md) | -| [#42](https://github.com/Matysh/houseplan-card/issues/42) Backend engineering quality | [042-backend-engineering-quality.md](042-backend-engineering-quality.md) | -| [#43](https://github.com/Matysh/houseplan-card/issues/43) Help/feedback and private support package | [043-private-support-report.md](043-private-support-report.md) | -| [#44](https://github.com/Matysh/houseplan-card/issues/44) Filtering/grouping policy | [044-filter-grouping-policy.md](044-filter-grouping-policy.md) | -| [#51](https://github.com/Matysh/houseplan-card/issues/51) Custom decor images | [051-custom-decor-images.md](051-custom-decor-images.md) | -| [#52](https://github.com/Matysh/houseplan-card/issues/52) Dimensions in View — закрыт, объединён с [#53](https://github.com/Matysh/houseplan-card/issues/53); файл остаётся контрактом измерений для экспорта PDF | [052-view-dimensions.md](052-view-dimensions.md) | -| [#53](https://github.com/Matysh/houseplan-card/issues/53) Экспорт пространства в PDF: чистый архитектурный план | [053-pdf-export.md](053-pdf-export.md) | -| [#54](https://github.com/Matysh/houseplan-card/issues/54) Zigbee topology overlay | [054-zigbee-topology-overlay.md](054-zigbee-topology-overlay.md) | -| [#55](https://github.com/Matysh/houseplan-card/issues/55) Independent Glow overlay | [055-independent-glow-overlay.md](055-independent-glow-overlay.md) | -| [#56](https://github.com/Matysh/houseplan-card/issues/56) Static room color | [056-static-room-color.md](056-static-room-color.md) | -| [#68](https://github.com/Matysh/houseplan-card/issues/68) Подсказки к настройкам | [068-help-affordance.md](068-help-affordance.md) | -| [#74](https://github.com/Matysh/houseplan-card/issues/74) Position-only Undo/Redo устройств | [074-device-position-undo.md](074-device-position-undo.md) | -| [#75](https://github.com/Matysh/houseplan-card/issues/75) + [#76](https://github.com/Matysh/houseplan-card/issues/76) Единый поток размещения проёмов | [075-076-opening-placement-flow.md](075-076-opening-placement-flow.md) | -| [#82](https://github.com/Matysh/houseplan-card/issues/82) Плавное масштабирование zoom/fit/reset | [082-smooth-zoom.md](082-smooth-zoom.md) | -| [#84](https://github.com/Matysh/houseplan-card/issues/84) Источник «Всегда» без сущности HA + [#88](https://github.com/Matysh/houseplan-card/issues/88) ведущая сущность | [084-passive-forced-light-sources.md](084-passive-forced-light-sources.md) | -| [#86](https://github.com/Matysh/houseplan-card/issues/86) Подсказки к настройкам, партия 1 | [086-settings-help-content-party1.md](086-settings-help-content-party1.md) | -| [#90](https://github.com/Matysh/houseplan-card/issues/90) Управляемый бейдж со значением | [090-device-value-badge.md](090-device-value-badge.md) | -| [#378](https://github.com/Matysh/houseplan-card/issues/378) Выбираемый источник для режима «Значение + состояние» | [378-value-face-source.md](378-value-face-source.md) | -| [#381](https://github.com/Matysh/houseplan-card/issues/381) Действие по нажатию «Ничего не делать» | [381-no-op-tap-action.md](381-no-op-tap-action.md) | -| [#383](https://github.com/Matysh/houseplan-card/issues/383) Плавные трансформации и зеркалирование мебели | [383-furniture-transform.md](383-furniture-transform.md) | -| [#94](https://github.com/Matysh/houseplan-card/issues/94) Универсальное действие «Переключить состояние» | [094-universal-state-toggle.md](094-universal-state-toggle.md) | -| [#101](https://github.com/Matysh/houseplan-card/issues/101) Плавный переход View ↔ редакторы | [101-view-editor-transition.md](101-view-editor-transition.md) | -| [#107](https://github.com/Matysh/houseplan-card/issues/107) Переключение виртуального источника света «Всегда» | [107-virtual-light-toggle.md](107-virtual-light-toggle.md) | -| [#113](https://github.com/Matysh/houseplan-card/issues/113) Optional-контракт SpaceModel | [113-optional-space-model.md](113-optional-space-model.md) | -| [#117](https://github.com/Matysh/houseplan-card/issues/117) Registry-less entity у проёма | [117-registryless-opening-entity.md](117-registryless-opening-entity.md) | -| [#122](https://github.com/Matysh/houseplan-card/issues/122) Изометрический режим Stage 2: скрытый режим и визуальная полировка | [122-isometric-stage2.md](122-isometric-stage2.md) | -| [#123](https://github.com/Matysh/houseplan-card/issues/123) Split из вершины не меняет наружную геометрию стен | [123-corner-split-wall.md](123-corner-split-wall.md) | -| [#132](https://github.com/Matysh/houseplan-card/issues/132) Проёмы в независимых стенах (+ bug [#185](https://github.com/Matysh/houseplan-card/issues/185)) | [132-partition-openings.md](132-partition-openings.md) | -| [#137](https://github.com/Matysh/houseplan-card/issues/137) Узлы и линии привязки в редакторе Плана | [137-plan-snap-overlay.md](137-plan-snap-overlay.md) | -| [#141](https://github.com/Matysh/houseplan-card/issues/141) Бесшовные стыки перегородок и открытых контуров | [141-wall-junctions.md](141-wall-junctions.md) | -| [#159](https://github.com/Matysh/houseplan-card/issues/159) Новый набор мебели и двухуровневая библиотека | [159-furniture-pack.md](159-furniture-pack.md) | -| [#160](https://github.com/Matysh/houseplan-card/issues/160) Изометрический режим Stage 3: глубина сцены, материалы и пространственные overlays | [160-isometric-stage3.md](160-isometric-stage3.md) | -| [#361](https://github.com/Matysh/houseplan-card/issues/361) Физическая толщина линий мебели при camera zoom | [361-furniture-stroke-zoom.md](361-furniture-stroke-zoom.md) | -| [#157](https://github.com/Matysh/houseplan-card/issues/157) Тип проёма «Открытый проём» | [157-open-passage.md](157-open-passage.md) | -| [#150](https://github.com/Matysh/houseplan-card/issues/150) Точная геометрия коллинеарного перепада толщины | [150-wall-thickness-transition.md](150-wall-thickness-transition.md) | -| [#152](https://github.com/Matysh/houseplan-card/issues/152) Click/tap по комнате вписывает её в View | [152-room-click-fit.md](152-room-click-fit.md) | -| [#172](https://github.com/Matysh/houseplan-card/issues/172) Нулевой Split-разделитель не получает ложную толщину | [172-zero-divider-taper.md](172-zero-divider-taper.md) | -| [#173](https://github.com/Matysh/houseplan-card/issues/173) Единый инструмент рисования стен и предложение комнаты по замыканию | [173-unified-wall-tool.md](173-unified-wall-tool.md) | -| [#174](https://github.com/Matysh/houseplan-card/issues/174) Связанный виртуальный источник следует реальному контроллеру | [174-linked-virtual-light-controller.md](174-linked-virtual-light-controller.md) | -| [#178](https://github.com/Matysh/houseplan-card/issues/178) Выбор сущности для действия «Переключить состояние» | [178-toggle-entity.md](178-toggle-entity.md) | -| [#197](https://github.com/Matysh/houseplan-card/issues/197) Один junction-патч не гасит кладку всего плана | [197-junction-patch-fail-dark.md](197-junction-patch-fail-dark.md) | -| [#204](https://github.com/Matysh/houseplan-card/issues/204) Честные defaults границ и имён при создании пространства | [204-space-create-display-defaults.md](204-space-create-display-defaults.md) | -| [#201](https://github.com/Matysh/houseplan-card/issues/201) Наследование толщины для атомарного участка стены | [201-atomic-thickness-lookup.md](201-atomic-thickness-lookup.md) | -| [#203](https://github.com/Matysh/houseplan-card/issues/203) Выключение названий скрывает все подписи комнат | [203-hide-room-names.md](203-hide-room-names.md) | -| [#186](https://github.com/Matysh/houseplan-card/issues/186) Безопасный остаток стены у торцов партиционного проёма | [186-partition-opening-jamb-margin.md](186-partition-opening-jamb-margin.md) | -| [#234](https://github.com/Matysh/houseplan-card/issues/234) Толщина отрезка цепочки не расходится между превью и записью | [234-chain-segment-thickness.md](234-chain-segment-thickness.md) | -| [#233](https://github.com/Matysh/houseplan-card/issues/233) Ресайз показывает внутренние размеры, а не осевые | [233-resize-inner-dimensions.md](233-resize-inner-dimensions.md) | -| [#300](https://github.com/Matysh/houseplan-card/issues/300) Понятные подписи и измеряемые стены во время Resize | [300-resize-measurement-layout.md](300-resize-measurement-layout.md) | -| [#238](https://github.com/Matysh/houseplan-card/issues/238) Размеры проёма до внутренних физических границ | [238-opening-inner-distances.md](238-opening-inner-distances.md) | -| [#242](https://github.com/Matysh/houseplan-card/issues/242) Символ проёма по центру толщины стены | [242-opening-symbol-center.md](242-opening-symbol-center.md) | -| [#244](https://github.com/Matysh/houseplan-card/issues/244) Восстановление маркеров с мёртвой ссылкой на пространство | [244-orphan-space-references.md](244-orphan-space-references.md) | -| [#248](https://github.com/Matysh/houseplan-card/issues/248) Идемпотентный Optimize после записи и reload | [248-optimize-idempotence.md](248-optimize-idempotence.md) | -| [#251](https://github.com/Matysh/houseplan-card/issues/251) Доступность контроллера не наследуется от управляемой цели | [251-controller-target-availability.md](251-controller-target-availability.md) | -| [#252](https://github.com/Matysh/houseplan-card/issues/252) Понятная и безопасная очистка забытых позиций в Optimize | [252-optimize-orphan-layout-report.md](252-optimize-orphan-layout-report.md) | -| [#253](https://github.com/Matysh/houseplan-card/issues/253) Resize не теряет интервалы толщины стен | [253-resize-wall-thickness.md](253-resize-wall-thickness.md) | -| [#258](https://github.com/Matysh/houseplan-card/issues/258) Канонический wall key после Optimize и storage round-trip | [258-wall-key-storage-roundtrip.md](258-wall-key-storage-roundtrip.md) | -| [#262](https://github.com/Matysh/houseplan-card/issues/262) Повторное добавление entity после удаления родительского устройства | [262-readd-child-entity-after-device-delete.md](262-readd-child-entity-after-device-delete.md) | -| [#265](https://github.com/Matysh/houseplan-card/issues/265) Единый контракт ссылочного шва импорта | [265-import-reference-seam.md](265-import-reference-seam.md) | -| [#267](https://github.com/Matysh/houseplan-card/issues/267) Таблица решений для «лица» маркера | [267-device-presentation-decision-table.md](267-device-presentation-decision-table.md) | -| [#274](https://github.com/Matysh/houseplan-card/issues/274) Беспроводной контроллер одинаково выглядит на плане и в preview | [274-wireless-controller-presentation-parity.md](274-wireless-controller-presentation-parity.md) | -| [#317](https://github.com/Matysh/houseplan-card/issues/317) Климат комнаты следует размещению датчика в House Plan | [317-room-climate-placement.md](317-room-climate-placement.md) | -| [#318](https://github.com/Matysh/houseplan-card/issues/318) Активный контроллер без собственных сущностей следует `controls` | [318-empty-controller-roster.md](318-empty-controller-roster.md) | -| [#373](https://github.com/Matysh/houseplan-card/issues/373) Плотное кадрирование static card по геометрии дома | [373-space-card-house-fit.md](373-space-card-house-fit.md) | -| [#294](https://github.com/Matysh/houseplan-card/issues/294) Esc завершает текущую цепочку стен без удаления геометрии | [294-wall-esc-detach.md](294-wall-esc-detach.md) | -| [#419](https://github.com/Matysh/houseplan-card/issues/419) Безопасная уборка Area-снапшота при пустом или усечённом HA-реестре | [419-area-snapshot-roster-guard.md](419-area-snapshot-roster-guard.md) | -| [#421](https://github.com/Matysh/houseplan-card/issues/421) Отрицательные доказательства для трёх защитных проверок | [421-negative-test-proofs.md](421-negative-test-proofs.md) | -| [#426](https://github.com/Matysh/houseplan-card/issues/426) Отключение информационного окна комнаты при наведении | [426-room-hover-tooltip-toggle.md](426-room-hover-tooltip-toggle.md) | -| [#431](https://github.com/Matysh/houseplan-card/issues/431) Канонизация координат пользовательских изображений | [431-image-coordinate-canonicalization.md](431-image-coordinate-canonicalization.md) | -| [#432](https://github.com/Matysh/houseplan-card/issues/432) Ограниченный resolve и единая проверка целостности изображений | [432-asset-resolve-authorization-cache.md](432-asset-resolve-authorization-cache.md) | -| [#454](https://github.com/Matysh/houseplan-card/issues/454) Счёт заходов и циклов ревью по артефактам | [454-review-round-counter.md](454-review-round-counter.md) | -| [#440](https://github.com/Matysh/houseplan-card/issues/440) Полиш аудита v1.71.0-beta.2 | [440-v171-beta2-polish.md](440-v171-beta2-polish.md) | -| [#445](https://github.com/Matysh/houseplan-card/issues/445) Магнит мебели к физической поверхности стены | [445-furniture-wall-face-snap.md](445-furniture-wall-face-snap.md) | -| [#447](https://github.com/Matysh/houseplan-card/issues/447) Наружная грань для мебели и сдвиг декора стрелками | [447-exterior-furniture-snap-keyboard-nudge.md](447-exterior-furniture-snap-keyboard-nudge.md) | -| [#448](https://github.com/Matysh/houseplan-card/issues/448) Единый бессрочный переключатель `hp_alpha` | [448-alpha-switch.md](448-alpha-switch.md) | -| [#449](https://github.com/Matysh/houseplan-card/issues/449) Двойной клик/тап по свободному фону вписывает весь план | [449-double-fit-all.md](449-double-fit-all.md) | -| [#451](https://github.com/Matysh/houseplan-card/issues/451) Фильтрация render и лёгкий live-слой взаимодействий | [451-render-performance.md](451-render-performance.md) | -| [#456](https://github.com/Matysh/houseplan-card/issues/456) Копирование пространства без комнат и устройств | [456-copy-space.md](456-copy-space.md) | -| [#457](https://github.com/Matysh/houseplan-card/issues/457) Направление Zigbee-связей к координатору | [457-zigbee-route-arrows.md](457-zigbee-route-arrows.md) | -| [#460](https://github.com/Matysh/houseplan-card/issues/460) Детерминированное завершение кадра живого редактора | [460-live-editor-settlement.md](460-live-editor-settlement.md) | -| [#461](https://github.com/Matysh/houseplan-card/issues/461) Быстрый commit промежуточной точки цепочки стен | [461-wall-draw-click-performance.md](461-wall-draw-click-performance.md) | -| [#464](https://github.com/Matysh/houseplan-card/issues/464) Верхний контекстный слой Zigbee-топологии | [464-zigbee-topology-layer-order.md](464-zigbee-topology-layer-order.md) | -| [#473](https://github.com/Matysh/houseplan-card/issues/473) Свидетели перф-дельты #160 и диффозависимый перф-смок | [473-iso-perf-witnesses-and-smoke.md](473-iso-perf-witnesses-and-smoke.md) | -| [#476](https://github.com/Matysh/houseplan-card/issues/476) Явное завершение выбора цвета кнопкой «ОК» | [476-color-picker-ok.md](476-color-picker-ok.md) | -| [#474](https://github.com/Matysh/houseplan-card/issues/474) Стартовый граф: арт мебели уходит в ленивый чанк | [474-lazy-furniture-art.md](474-lazy-furniture-art.md) | - -## P3 - -| Issue | ТЗ | -|---|---| -| [#209](https://github.com/Matysh/houseplan-card/issues/209) Плавный след пылесоса | [209-vacuum-trail-smoothing.md](209-vacuum-trail-smoothing.md) | -| [#198](https://github.com/Matysh/houseplan-card/issues/198) Optimize очищает изолированный микро-интервал толщины | [198-optimize-micro-interval.md](198-optimize-micro-interval.md) | -| [#103](https://github.com/Matysh/houseplan-card/issues/103) Состояния в Toggle confirmation | [103-toggle-confirmation-state.md](103-toggle-confirmation-state.md) | -| [#200](https://github.com/Matysh/houseplan-card/issues/200) Одинаковая геометрия подписи комнаты в View и Plan editor | [200-room-label-parity.md](200-room-label-parity.md) | -| [#340](https://github.com/Matysh/houseplan-card/issues/340) Обязательная ревизия повторной записи config/set | [340-config-set-revision.md](340-config-set-revision.md) | -| [#372](https://github.com/Matysh/houseplan-card/issues/372) Компактное верхнее кадрирование static card без заголовка | [372-space-card-empty-title.md](372-space-card-empty-title.md) | -| [#423](https://github.com/Matysh/houseplan-card/issues/423) Полиш support pipeline и защитных инструментов v1.70.0 | [423-v170-polish.md](423-v170-polish.md) | -| [#434](https://github.com/Matysh/houseplan-card/issues/434) Полиш аудита v1.71.0-beta.1 | [434-v171-polish-audit.md](434-v171-polish-audit.md) | -| [#443](https://github.com/Matysh/houseplan-card/issues/443) Полиш маршрутов карт робота | [443-vacuum-route-polish.md](443-vacuum-route-polish.md) | - -## P3 - -| Issue | ТЗ | -|---|---| -| [#57](https://github.com/Matysh/houseplan-card/issues/57) Единый выбор цвета и прозрачности | [057-color-opacity-picker.md](057-color-opacity-picker.md) | -| [#180](https://github.com/Matysh/houseplan-card/issues/180) Единый picker во всех местах выбора цвета | [180-all-color-call-sites.md](180-all-color-call-sites.md) | - -## P3 - -| Issue | ТЗ | -|---|---| -| [#62](https://github.com/Matysh/houseplan-card/issues/62) Масштабируемая i18n-инфраструктура | [062-i18n-registry.md](062-i18n-registry.md) | - -## Правило актуализации - -При изменении продуктового решения сначала обновляется соответствующее issue, затем ТЗ. Реализация не считается завершённой только по наличию кода: нужны выполненные acceptance criteria, предусмотренная ТЗ проверка и актуальный статус Project v2. +Отсутствие этого раздела не означает, что документация необязательна. Для чистого refactoring ТЗ должно прямо зафиксировать отсутствие пользовательских изменений и перечислить технические доказательства безопасного поведения. diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index a1c146b1..cacd4470 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -8192,6 +8192,50 @@ const MUTANT_DEFINITIONS = [ replace: " // mutant: completed means green — a red dispatch falls through to the job check", }], }, + { + id: 'review-anchor-drops-issue-body', + guard: 'node --test test/review-doc-guard.test.mjs', + because: 'ТЗ живёт в теле issue, которое GitHub правит без diff и без истории: без хеша тела ' + + 'в якорях доказать «вердикт вынесен на этом тексте» нечем (#517 AC1)', + patches: [{ + file: 'scripts/review-doc-guard.mjs', + find: ' lines.push(`- Тело issue: \\`${issueBody}\\``);', + replace: ' // mutant: anchor without the issue body', + }], + }, + { + id: 'review-ignores-changed-spec-body', + guard: 'node --test test/review-doc-guard.test.mjs', + because: 'правка ТЗ после зелёного ревью ТЗ обязана приходить ревьюеру кода находкой; ' + + 'сравнение, которое всегда молчит, возвращает ровно ту слепоту, ради которой заведён хеш (#517 AC2)', + patches: [{ + file: 'scripts/review-doc-guard.mjs', + find: ' return recorded === digest ? null : { doc: green.name, recorded, current: digest };', + replace: ' return null; // mutant: the body never changed', + }], + }, + { + id: 'reuse-ignores-changed-issue-body', + guard: 'node --test test/review-doc-guard.test.mjs', + because: 'повторное применение зелёного вердикта пропускает вызов модели целиком: если оно не ' + + 'смотрит на хеш тела, правка ТЗ между раундами проходит невидимой (#517 AC6)', + patches: [{ + file: 'scripts/review-doc-guard.mjs', + find: ' if (recordedBody && issueBodyDigest && recordedBody !== issueBodyDigest) return null;', + replace: ' // mutant: reuse ignores the issue body', + }], + }, + { + id: 'process-gate-requires-spec-file', + guard: 'node --test test/process-gate.test.mjs', + because: 'после #517 файла ТЗ не создаёт ни одна новая задача: гейт, требующий файл, краснеет ' + + 'на каждом классе A и учит игнорировать себя (#517 AC3)', + patches: [{ + file: 'scripts/process-gate.mjs', + find: ' if (hasSpecText(body)) continue;', + replace: ' if (body !== null) continue; // mutant: only the file counts', + }], + }, { id: 'review-returns-task-on-cancelled-dispatch', guard: 'node --test test/validate-gate.test.mjs', diff --git a/scripts/process-gate.mjs b/scripts/process-gate.mjs index 2afe80d9..bb48edb4 100644 --- a/scripts/process-gate.mjs +++ b/scripts/process-gate.mjs @@ -69,11 +69,6 @@ const CLASS_C = [ const CHANGELOGS = ['docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md']; -// Метки, при которых файла ТЗ в docs/specs/ быть не должно: на лёгком треке ТЗ -// живёт в теле issue (§5), на коротком — там же, и ревью ТЗ вообще не проводится -// (§5.1, issue #128). Офлайн эти случаи неотличимы от «ТЗ не написано», поэтому -// проверка 3 краснеет только когда метки прочитаны. -export const NO_SPEC_FILE = ['small', 'trivial']; export const ALLOWED_STATUS = ['S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged']; export const STRICT_STATUS = ['S5-ready', 'S6-in-progress', 'S7-code-review']; @@ -124,7 +119,7 @@ export function isReleaseCommit(subject, one) { export function makeCommit({ sha = '', subject = '', body = '', files = [], authorDate = '', - releaseSourceViolations = null, + releaseSourceViolations = null, addedFiles = null, }) { const text = `${subject}\n${body}`; const all = (name) => @@ -136,6 +131,9 @@ export function makeCommit({ subject, authorDate, files, + // Добавленные файлы (`--diff-filter=A`) нужны одной проверке — заморозке + // docs/specs (#517). Не доказаны вызывающим — null, и проверка молчит. + addedFiles, classes: new Set(files.map(classify)), issues: all('Issue'), userVisible: one('User-Visible'), @@ -151,7 +149,7 @@ export function makeCommit({ } export function parseRecords( - raw, filesOf = () => [], releaseSourceViolationsOf = () => null, + raw, filesOf = () => [], releaseSourceViolationsOf = () => null, addedFilesOf = null, ) { if (!raw.trim()) return []; return raw @@ -168,6 +166,7 @@ export function parseRecords( const one = (name) => text.match(new RegExp(`^${name}:\\s*(.+)$`, 'mi'))?.[1].trim() ?? null; return makeCommit({ sha, subject, body, files, authorDate, + addedFiles: addedFilesOf ? addedFilesOf(sha) : null, releaseSourceViolations: isReleaseCommit(subject, one) ? releaseSourceViolationsOf(sha, files) : null, }); @@ -299,40 +298,68 @@ export function checkBranchRule(branch, commits) { return out; } -// 3. для класса A нужно ТЗ docs/specs/NN-*.md, либо метка small (лёгкий трек). -// Офлайн это предупреждение: лёгкий трек держит ТЗ в теле issue, и без чтения -// меток отличить «ТЗ в issue» от «ТЗ не написано» невозможно. С метками — отказ. -export function checkSpecs(commits, specFiles, labelsOf = null) { - if (specFiles === null) { - return [{ level: 'warn', rule: 3, sha: '-', msg: 'нет docs/specs/ — проверка 3 пропущена' }]; - } +// 3. у класса A есть ТЗ — в теле issue (#517) либо в архивном файле +// docs/specs/NN-*.md у задач до 2026-09-10. +// +// Что изменилось. Прежняя редакция требовала ФАЙЛА и отличала «ТЗ в теле» от +// «ТЗ нет» только по меткам `small`/`trivial`. С переходом на ТЗ в теле issue +// (#517) файла нет ни у одной новой задачи, и проверка по файлу требовала бы +// того, чего процесс больше не создаёт. Судится текст: тело с заголовком +// `## ТЗ` или хотя бы одним `AC1` — это ТЗ; тело без обоих признаков — код без +// ТЗ. Офлайн (без `--issues`) тела нет, и проверка честно молчит: угадывать по +// меткам больше нечего. +// +// Уровень — предупреждение, а не отказ: признак текстовый, и ложный красный +// здесь дороже пропуска. Настоящий рубеж — ревью ТЗ, оно без ТЗ не бывает +// зелёным. +export function checkSpecs(commits, specFiles, bodyOf = null) { const out = []; const seen = new Set(); + // `\b` в JS считает границей только латиницу с цифрами, поэтому после + // кириллического «ТЗ» её нет — признак ищется явным концом слова. + const hasSpecText = (body) => /^[ \t]*#{1,6}[ \t]*ТЗ(?![\p{L}\p{N}])/mu.test(String(body ?? '')) + || /(? new RegExp(`^0*${nn}[-_]`).test(f))) continue; + // Архивный файл ТЗ у старой задачи — по-прежнему ТЗ. + if (specFiles && specFiles.some((f) => new RegExp(`^0*${nn}[-_]`).test(f))) continue; - const labels = labelsOf ? labelsOf(nn) : null; - if (labels === null) { - out.push({ - level: 'warn', rule: 3, sha: c.short, - msg: `класс A по ${t}, но ТЗ docs/specs/${nn}-*.md не найдено — допустимо при метке small или trivial`, - }); - } else if (!labels.some((l) => NO_SPEC_FILE.includes(l))) { - out.push({ - level: 'fail', rule: 3, sha: c.short, - msg: `класс A по ${t}: ТЗ docs/specs/${nn}-*.md нет, и метки ${NO_SPEC_FILE.join(' / ')} на issue нет — код без ТЗ`, - }); - } + const body = bodyOf ? bodyOf(nn) : null; + if (body === null) continue; // офлайн: тела нет, судить нечем + if (hasSpecText(body)) continue; + out.push({ + level: 'warn', rule: 3, sha: c.short, + msg: `класс A по ${t}: в теле issue нет ни раздела «## ТЗ», ни AC1, архивного ТЗ docs/specs/${nn}-*.md тоже нет — код без ТЗ`, + }); } } return out; } +/** + * Новые файлы в `docs/specs/**` — предупреждение: каталог заморожен (#517). + * + * Старые ТЗ остаются и редактируются владельцем свободно; добавление нового + * файла означает, что кто-то пишет ТЗ по отменённой схеме. + */ +export function checkFrozenSpecs(commits) { + const out = []; + for (const c of commits) { + const added = (c.addedFiles || []).filter((f) => /^docs\/specs\/[^/]+\.md$/.test(f) + && !/README\.md$/.test(f)); + if (!added.length) continue; + out.push({ + level: 'warn', rule: 3, sha: c.short, + msg: `новый файл ТЗ ${added.join(', ')} — docs/specs/ заморожен с 2026-09-10 (#517), ТЗ живёт в теле issue`, + }); + } + return out; +} + // 7. документов ревью на issue не больше шести. // // Порог НЕ равен лимиту циклов (§4), и это не небрежность. Документ нумеруется @@ -600,7 +627,7 @@ function ghTimelineRunner(nwo, bin) { function ghRunner(nwo, bin) { return (nn) => { - const r = spawnSync(bin, ['issue', 'view', String(nn), '--repo', nwo, '--json', 'number,state,labels'], + const r = spawnSync(bin, ['issue', 'view', String(nn), '--repo', nwo, '--json', 'number,state,labels,body'], { encoding: 'utf8' }); return r.status === 0 ? { ok: true, json: r.stdout } @@ -696,10 +723,14 @@ function main(argv) { .filter((file) => !isReleaseVersionOnlyChange( file, blobOf(`${sha}^`, file), blobOf(sha, file), )); + const addedFilesOf = (sha) => + git(['show', '--name-only', '--diff-filter=A', '--pretty=format:', sha], repo) + .split('\n').map((s) => s.trim()).filter(Boolean); const commits = parseRecords( git(['log', '--reverse', `--pretty=format:${LOG_FORMAT}`, range], repo), filesOf, releaseSourceViolationsOf, + addedFilesOf, ); const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], repo).trim(); @@ -747,6 +778,7 @@ function main(argv) { // Метки читаются один раз и используются дважды: проверкой 8 и escalation // проверки 3. Второй запрос по тому же issue — лишний сетевой вызов. let labelsOf = null; + let bodyOf = null; if (flag('issues')) { const prereleaseTags = isStableTarget(targetRef) ? git(['tag', '--list'], repo).split('\n').map((s) => s.trim()).filter((tag) => @@ -801,12 +833,24 @@ function main(argv) { return null; } }; + // Тело issue — материал ТЗ с #517; по нему судит проверка 3. + bodyOf = (nn) => { + const r = cached(nn); + if (!r || r.ok !== true) return null; + try { + const issue = typeof r.json === 'string' ? JSON.parse(r.json) : r.json; + return typeof issue.body === 'string' ? issue.body : null; + } catch { + return null; + } + }; } const specsDir = join(repo, 'docs', 'specs'); findings.push(...checkSpecs( - checkedCommits, existsSync(specsDir) ? readdirSync(specsDir) : null, labelsOf, + checkedCommits, existsSync(specsDir) ? readdirSync(specsDir) : null, bodyOf, )); + findings.push(...checkFrozenSpecs(checkedCommits)); const reviewDir = join(repo, 'docs', 'reviews'); const reviewFiles = [ diff --git a/scripts/review-doc-guard.mjs b/scripts/review-doc-guard.mjs index ed4acd99..2237c5ea 100644 --- a/scripts/review-doc-guard.mjs +++ b/scripts/review-doc-guard.mjs @@ -23,6 +23,7 @@ * который пуш добавит в целевую ветку. Пустой список — тоже отказ: публиковать * нечего, значит что-то пошло не так раньше. */ +import { createHash } from 'node:crypto'; import { spawnSync } from 'node:child_process'; import { readFileSync, writeFileSync } from 'node:fs'; import { isMainModule } from './spawn-portable.mjs'; @@ -253,7 +254,60 @@ export function anchorLiveness(object, run) { return false; } -export function materialAnchorBlock({ sha, tree, branch, specs = [], verdict, high } = {}) { +/** + * Нормализация тела issue перед хешем (#517). + * + * GitHub отдаёт тело с `\r\n`, а веб-редактор дописывает пробелы в концах + * строк — без нормализации хеш менялся бы от правок, которых в тексте нет. + * Нормализуется ровно это: перевод строки, хвостовые пробелы каждой строки и + * финальные пустые строки. Содержательные пробелы внутри строк не трогаются: + * в ТЗ есть таблицы и блоки кода, где они значимы. + */ +export function normalizeIssueBody(text) { + return String(text ?? '') + .replace(/\r\n?/g, '\n') + .split('\n') + .map((line) => line.replace(/[ \t]+$/, '')) + .join('\n') + .replace(/\n+$/, ''); +} + +/** sha256 нормализованного тела issue — материал ревью ТЗ (#517). */ +export function issueBodyDigest(text) { + return createHash('sha256').update(normalizeIssueBody(text), 'utf8').digest('hex'); +} + +/** Хеш тела из машинного блока документа ревью, либо `null` (#517). */ +export function anchorIssueBodyFrom(text) { + const body = String(text ?? ''); + const at = body.indexOf(ANCHOR_MARKER); + if (at < 0) return null; + const match = body.slice(at).match(/Тело issue: `([0-9a-f]{64})`/); + return match ? match[1] : null; +} + +/** + * Менялось ли ТЗ после зелёного ревью ТЗ (#517 AC2). + * + * `docs` — документы этапа spec, `digest` — хеш тела на момент этого захода. + * Сравнивается запись конвейера в последнем ЗЕЛЁНОМ документе: проза цитирует + * прошлые раунды и источником быть не может. Нет зелёного документа или в нём + * нет строки `Тело issue:` (задачи до перехода) — сравнивать не с чем, и это + * не находка: молчание здесь честнее выдуманного «менялось». + */ +export function issueBodyChanged(docs, digest) { + const numbered = (docs || []) + .map((doc) => ({ ...doc, round: Number((String(doc?.name || '').match(/-r(\d+)\.md$/) || [])[1]) })) + .filter((doc) => Number.isFinite(doc.round)) + .sort((a, b) => b.round - a.round); + const green = numbered.find((doc) => anchorVerdictFrom(doc.text)?.verdict === 'green'); + if (!green) return null; + const recorded = anchorIssueBodyFrom(green.text); + if (!recorded || !digest) return null; + return recorded === digest ? null : { doc: green.name, recorded, current: digest }; +} + +export function materialAnchorBlock({ sha, tree, branch, specs = [], verdict, high, issueBody } = {}) { const short = (value) => (typeof value === 'string' ? value.slice(0, 12) : ''); const lines = [ ANCHOR_MARKER, @@ -269,13 +323,20 @@ export function materialAnchorBlock({ sha, tree, branch, specs = [], verdict, hi lines.push(` git log --all --format='%H %T' | grep ${short(tree)}`); lines.push(' ```'); } + // Тело issue — материал ревью ТЗ на лёгком и (с #517) на полном треке. Файл + // ТЗ может отсутствовать вовсе; хеш тела есть всегда, и он единственное, + // чем доказуемо «вердикт вынесен на этом тексте»: GitHub хранит правки тела + // без diff и без истории, доступной чтением. + if (issueBody) { + lines.push(`- Тело issue: \`${issueBody}\``); + } for (const spec of specs) { lines.push(`- ТЗ \`${spec.path}\`, блоб \`${spec.blob}\``); lines.push(' ```'); lines.push(` git log --all --find-object=${spec.blob} -- ${spec.path}`); lines.push(' ```'); } - if (!tree && !specs.length) { + if (!tree && !specs.length && !issueBody) { lines.push('- Якоря снять не удалось: ветки задачи нет, материал читался по `dev`.'); } // Вердикт из structured_output модели, записанный конвейером (#499): по нему @@ -477,7 +538,7 @@ export function anchorVerdictFrom(text) { * @param docs [{ name, text }] опубликованные документы этапа этой задачи * @param differs (tree) => boolean — есть ли отличие дерева от HEAD вне docs/reviews */ -export function reusableGreenVerdict(docs, differs) { +export function reusableGreenVerdict(docs, differs, issueBodyDigest = null) { const numbered = (docs || []) .map((doc) => ({ ...doc, round: Number((String(doc?.name || '').match(/-r(\d+)\.md$/) || [])[1]) })) .filter((doc) => Number.isFinite(doc.round)) @@ -492,6 +553,13 @@ export function reusableGreenVerdict(docs, differs) { const tree = anchorTreeFrom(latest.text); if (!tree) return null; if (differs(tree)) return null; + // Дерево не знает о теле issue, а с #517 ТЗ живёт именно там: без этой + // проверки правка ТЗ между раундами проходила бы невидимой — вызов модели + // пропущен, находка «ТЗ менялось» некому напечатать (ревью ТЗ #517 r1). + // Документ без записи (весь бэклог до перехода) судится по дереву, как + // раньше: иначе переход обнулил бы reuse для всех накопленных задач. + const recordedBody = anchorIssueBodyFrom(latest.text); + if (recordedBody && issueBodyDigest && recordedBody !== issueBodyDigest) return null; return { doc: latest.name, round: latest.round, tree, verdict: 'green' }; } @@ -697,7 +765,7 @@ if (invokedDirectly) { const diff = spawnSync('git', ['diff', '--quiet', tree, head, '--', '.', ':!docs/reviews'], { encoding: 'utf8' }); return diff.status !== 0; }; - const found = reusableGreenVerdict(docs, differs); + const found = reusableGreenVerdict(docs, differs, value('issue-body')); if (!found) { process.stdout.write('reuse=false\n'); process.exit(0); } process.stdout.write(`reuse=true\ndoc=${found.doc}\nround=${found.round}\ntree=${found.tree}\n`); process.exit(0); @@ -715,6 +783,7 @@ if (invokedDirectly) { sha: value('sha'), tree: value('tree'), branch: value('branch'), + issueBody: /^[0-9a-f]{64}$/.test(value('issue-body')) ? value('issue-body') : '', specs: parseSpecList(value('specs')), verdict: ['green', 'yellow', 'red'].includes(value('verdict')) ? value('verdict') : '', high: value('high'), @@ -722,7 +791,7 @@ if (invokedDirectly) { const text = readFileSync(path, 'utf8'); writeFileSync(path, withMaterialAnchors(text, anchors), 'utf8'); console.log(`якоря материала дописаны: дерево ${anchors.tree.slice(0, 12) || '—'},` - + ` ТЗ ${anchors.specs.length}`); + + ` ТЗ ${anchors.specs.length}, тело issue ${anchors.issueBody.slice(0, 12) || '—'}`); process.exit(0); } diff --git a/scripts/task-packet.mjs b/scripts/task-packet.mjs index 67a97489..4570cb22 100644 --- a/scripts/task-packet.mjs +++ b/scripts/task-packet.mjs @@ -107,7 +107,13 @@ export function buildPacket(inputs) { const track = labels.includes('trivial') ? 'trivial' : labels.includes('small') ? 'small' : 'полный'; const stage = status === 'S4-spec-review' || status === 'S3-spec' || status === 'S5-ready' ? 'spec' : 'code'; const verdict = lastVerdict(comments, reviewDocs, stage); - const acSource = specs.length ? specs.map((s) => s.text).join('\n') : issue.body; + // ТЗ живёт в теле issue (#517); архивный файл — источник только у задач до + // перехода, у которых в теле AC нет. Порядок именно такой: тело правится и + // после создания файла, и тогда файл описывает не тот текст, что читает + // ревьюер. + const fromBody = extractAcceptanceCriteria(issue.body); + const acSource = fromBody.length || !specs.length + ? issue.body : specs.map((s) => s.text).join('\n'); const acs = evidenceFor(extractAcceptanceCriteria(acSource), reviewDocs.length ? reviewDocs.at(-1).text : ''); const unverified = acs.filter((a) => a.evidence.startsWith('без записи')); const packet = { diff --git a/test/process-gate.test.mjs b/test/process-gate.test.mjs index 3d31fa88..6e62bc26 100644 --- a/test/process-gate.test.mjs +++ b/test/process-gate.test.mjs @@ -16,6 +16,7 @@ import { checkIssueStatuses, checkReviewDocLimit, REVIEW_DOC_LIMIT, + checkFrozenSpecs, checkSpecs, clampIssueBranchRange, classify, @@ -163,27 +164,37 @@ test('the branch name must agree with the Issue trailers', () => { assert.deepEqual(checkBranchRule('dev', [c]), []); }); -test('a class A commit without a spec warns offline and fails with labels', () => { +test('#517 AC3: ТЗ класса A судится по телу issue, архивный файл тоже годится', () => { const c = commit('Fix', 'Issue: #104', ['src/a.ts']); + // Архивный файл ТЗ старой задачи — по-прежнему ТЗ. assert.deepEqual(checkSpecs([c], ['104-opening-ha-reference.md']), []); - // Офлайн отличить «ТЗ в теле issue» от «ТЗ нет» нельзя — только предупреждение. - const offline = checkSpecs([c], ['111-something-else.md']); - assert.equal(offline.length, 1); - assert.equal(offline[0].level, 'warn'); - assert.equal(offline[0].rule, 3); + // Тело с разделом «## ТЗ» либо с AC1 — это ТЗ, файла не требуется. + assert.deepEqual(checkSpecs([c], [], () => '## ТЗ\n\nконтракт'), []); + assert.deepEqual(checkSpecs([c], [], () => 'Проблема\n\n- AC1. Так и так'), []); + assert.deepEqual(checkSpecs([c], null, () => '### ТЗ (лёгкий трек)'), []); - // С метками: small и trivial оправдывают отсутствие файла, их отсутствие — нет. - assert.deepEqual(checkSpecs([c], [], () => ['small', 'S5-ready']), []); - assert.deepEqual(checkSpecs([c], [], () => ['trivial', 'S5-ready']), []); - const strict = checkSpecs([c], [], () => ['S5-ready']); - assert.equal(strict.length, 1); - assert.equal(strict[0].level, 'fail'); - assert.equal(strict[0].rule, 3); + // Ни того, ни другого — предупреждение: настоящий рубеж — ревью ТЗ. + const bare = checkSpecs([c], ['111-something-else.md'], () => 'просто описание бага'); + assert.equal(bare.length, 1); + assert.equal(bare[0].level, 'warn'); + assert.equal(bare[0].rule, 3); + assert.match(bare[0].msg, /в теле issue нет/); - // Метки недоступны — падать обратно на предупреждение: за недоступность - // отвечает проверка 8, она уже краснеет fail closed. - assert.equal(checkSpecs([c], [], () => null)[0].level, 'warn'); + // Офлайн тела нет — судить нечем, молчим (метки больше ничего не решают). + assert.deepEqual(checkSpecs([c], [], () => null), []); + assert.deepEqual(checkSpecs([c], null), []); +}); + +test('#517: docs/specs заморожен — новый файл ТЗ даёт предупреждение, правка старого нет', () => { + const added = { ...commit('Spec', 'Issue: #700', ['docs/specs/700-new.md']), addedFiles: ['docs/specs/700-new.md'] }; + const edited = { ...commit('Fix typo', 'Issue: #162', ['docs/specs/162-old.md']), addedFiles: [] }; + const readme = { ...commit('Archive note', 'Issue: #517', ['docs/specs/README.md']), addedFiles: ['docs/specs/README.md'] }; + const unknown = commit('Spec', 'Issue: #700', ['docs/specs/700-new.md']); // addedFiles не доказаны + const out = checkFrozenSpecs([added, edited, readme, unknown]); + assert.equal(out.length, 1); + assert.equal(out[0].level, 'warn'); + assert.match(out[0].msg, /docs\/specs\/700-new\.md/); }); test('a rebase re-run may exceed the cycle limit in documents (#227)', () => { diff --git a/test/review-doc-guard.test.mjs b/test/review-doc-guard.test.mjs index 8a0d6100..30d975af 100644 --- a/test/review-doc-guard.test.mjs +++ b/test/review-doc-guard.test.mjs @@ -7,6 +7,7 @@ import { attemptFromRounds, blockingFromDocs, isBlockingVerdict, reviewCounters, reviewRoundsFromFiles, verdictDeclaration, commentCounters, stageVerdictComments, anchorTreeFrom, anchorVerdictFrom, reusableGreenVerdict, + anchorIssueBodyFrom, issueBodyChanged, issueBodyDigest, normalizeIssueBody, } from '../scripts/review-doc-guard.mjs'; // #365. 28.08 шаг публикации ревью-дока запушил в dev коммит bb2919f с тридцатью @@ -652,3 +653,93 @@ test('#515: якоря материала снимаются ПОСЛЕ ребе // до-ребейзные якоря из шага branch никем не читаются: после force-push они мертвы (#508 r1–r3) assert.doesNotMatch(workflow, /steps\.branch\.outputs\.(sha|tree|specs)/, 'якоря из шага branch — осиротевшие после ребейза'); }); + +// --- ТЗ живёт в теле issue: хеш тела как якорь материала (#517) -------------- + +const BODY_A = issueBodyDigest('## ТЗ\n\n- AC1. Как было'); +const BODY_B = issueBodyDigest('## ТЗ\n\n- AC1. Как стало'); + +test('#517 AC1: нормализация тела гасит правки, которых в тексте нет', () => { + const canonical = '## ТЗ\n\n- AC1. Строка\n- AC2. Вторая'; + assert.equal(normalizeIssueBody('## ТЗ\r\n\r\n- AC1. Строка \r\n- AC2. Вторая\n\n\n'), canonical); + assert.equal(issueBodyDigest('## ТЗ\r\n\r\n- AC1. Строка \n- AC2. Вторая'), issueBodyDigest(canonical)); + // Содержательный пробел внутри строки значим: в ТЗ есть таблицы и код. + assert.notEqual(issueBodyDigest('| AC1 | текст |'), issueBodyDigest('| AC1 | текст |')); + assert.match(BODY_A, /^[0-9a-f]{64}$/); + assert.notEqual(BODY_A, BODY_B); +}); + +test('#517 AC1: блок якорей несёт хеш тела и читается обратно', () => { + const block = materialAnchorBlock({ tree: TREE_A, issueBody: BODY_A, verdict: 'green', high: 0 }); + assert.match(block, new RegExp(`- Тело issue: \`${BODY_A}\``)); + assert.equal(anchorIssueBodyFrom(block), BODY_A); + // Старые документы без строки читаются как раньше, а не как «хеш пустой». + assert.equal(anchorIssueBodyFrom(materialAnchorBlock({ tree: TREE_A })), null); + assert.doesNotMatch(materialAnchorBlock({ tree: TREE_A }), /Тело issue/); + // Проза вне машинного блока записью не считается. + assert.equal(anchorIssueBodyFrom(`Тело issue: \`${BODY_A}\`\nбез маркера`), null); + // Ветки задачи нет, но тело есть — материал воспроизводим, отговорки нет. + assert.doesNotMatch(materialAnchorBlock({ issueBody: BODY_A }), /Якоря снять не удалось/); + assert.match(materialAnchorBlock({}), /Якоря снять не удалось/); +}); + +test('#517 AC2: правка тела после зелёного ревью ТЗ — находка; неизменённое молчит', () => { + const green = docWith('SPEC-REVIEW-9-r2.md', { tree: TREE_A, verdict: 'green', high: 0, issueBody: BODY_A }); + const yellow = docWith('SPEC-REVIEW-9-r1.md', { tree: TREE_B, verdict: 'yellow', high: 0, issueBody: BODY_B }); + assert.equal(issueBodyChanged([yellow, green], BODY_A), null, 'текст тот же — молчим'); + const changed = issueBodyChanged([yellow, green], BODY_B); + assert.equal(changed.doc, 'SPEC-REVIEW-9-r2.md'); + assert.equal(changed.recorded, BODY_A); + assert.equal(changed.current, BODY_B); + // Судится последний ЗЕЛЁНЫЙ, а не последний вообще. + const red = docWith('SPEC-REVIEW-9-r3.md', { tree: TREE_A, verdict: 'red', high: 1, issueBody: BODY_B }); + assert.equal(issueBodyChanged([green, red], BODY_A), null); + // Задачи до перехода: зелёного документа нет либо в нём нет записи — не находка. + assert.equal(issueBodyChanged([yellow], BODY_B), null); + assert.equal(issueBodyChanged([docWith('SPEC-REVIEW-9-r1.md', { tree: TREE_A, verdict: 'green', high: 0 })], BODY_A), null); + assert.equal(issueBodyChanged([green], ''), null, 'хеша нет — сравнивать не с чем'); +}); + +test('#517 AC6: reuse не применяет зелёный вердикт, если тело issue изменилось', () => { + const green = docWith('CODE-REVIEW-9-r2.md', { tree: TREE_A, verdict: 'green', high: 0, issueBody: BODY_A }); + assert.ok(reusableGreenVerdict([green], () => false, BODY_A), 'тело то же — вердикт применим'); + assert.equal(reusableGreenVerdict([green], () => false, BODY_B), null, 'тело менялось — нужен разбор'); + // Документ без записи (весь бэклог до перехода) судится по дереву, как раньше. + const legacy = docWith('CODE-REVIEW-9-r2.md', { tree: TREE_A, verdict: 'green', high: 0 }); + assert.ok(reusableGreenVerdict([legacy], () => false, BODY_B)); + assert.ok(reusableGreenVerdict([green], () => false), 'хеш не передан — прежнее поведение'); +}); + +test('#517: конвейер снимает хеш тела на материале и передаёт его в якоря, reuse и ревьюеру', () => { + const workflow = readFileSync(new URL('../.github/workflows/process.yml', import.meta.url), 'utf8'); + const at = (marker) => { const i = workflow.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; }; + const material = at(' - name: Зафиксировать SHA материала ревью\n'); + const reuse = at(' - name: "Зелёный вердикт прошлого захода применим без ревью (#499)"\n'); + const specBody = at(' - name: "ТЗ менялось после зелёного ревью ТЗ (#517)"\n'); + const review = at(' - name: Review\n'); + assert.ok(material < reuse && reuse < specBody && specBody < review, 'хеш снят до reuse, находка — до модели'); + const materialStep = workflow.slice(material, reuse); + assert.match(materialStep, /gh issue view "\$NUM" --repo "\$REPO" --json body/, 'тело читается в прогоне, не из события'); + assert.match(materialStep, /echo "issue_body=\$digest" >> "\$GITHUB_OUTPUT"/); + assert.match(workflow.slice(reuse, specBody), /--issue-body="\$\{ISSUE_BODY\}"/, 'reuse учитывает тело (AC6)'); + assert.match(workflow, /--issue-body="\$MATERIAL_ISSUE_BODY"/, 'якорь попадает в документ'); + assert.match(workflow, /steps\.spec_body\.outputs\.changed == 'true' &&/, 'находка уходит в промпт ревьюера'); +}); + +test('#517 AC4: документы процесса не требуют файла ТЗ, индекс docs/specs удалён', () => { + const read = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8'); + const process = read('PROCESS.md'); + // Цепочка §7.1 называет тело issue, а не файл. + assert.match(process, /↔ ТЗ\s+тело issue, раздел `## ТЗ`/); + assert.doesNotMatch(process, /Артефакт:\*\* `docs\/specs\//, 'файл ТЗ больше не артефакт этапа S3'); + // Гейт судит текст, а не наличие файла. + assert.match(process, /у класса A есть ТЗ: раздел `## ТЗ` или хотя бы один `AC1`/); + const agents = read('AGENTS.md'); + assert.match(agents, /spec lives in the \*\*issue body\*\*/); + assert.match(agents, /`docs\/specs\/` is an archive/); + // README архива: ни одной строки индекса «| [#NN](…) | […](….md) |». + const readme = read('docs/specs/README.md'); + assert.match(readme, /архив/i); + assert.equal((readme.match(/^\| \[#\d+\]/gm) || []).length, 0, 'таблица-индекс удалена'); + assert.doesNotMatch(readme, /Статус ТЗ/); +}); diff --git a/test/task-packet.test.mjs b/test/task-packet.test.mjs index 81c17af0..ec25bdc4 100644 --- a/test/task-packet.test.mjs +++ b/test/task-packet.test.mjs @@ -89,3 +89,19 @@ test('пакет собирается и рендерится: статус, м // Ничего не додумывается: без ветки — прямо сказано, что материал не запушен. assert.match(renderPacket(buildPacket({ issue: { number: 1, title: 't', state: 'OPEN', body: '' }, labels: [] })), /материал не запушен/); }); + +test('#517 AC5: AC берутся из тела issue, файл ТЗ — только когда в теле их нет', () => { + const base = { + issue: { number: 700, title: 'x', state: 'OPEN', url: 'u', body: '## ТЗ\n\n- AC1. Из тела\n' }, + labels: ['S6-in-progress'], comments: [], + specs: [{ name: '700-old.md', text: '| AC1 | Из файла |\n| AC2 | Тоже из файла |' }], + }; + const fromBody = buildPacket(base); + assert.deepEqual(fromBody.acceptance.map((a) => a.text), ['Из тела'], 'тело важнее файла'); + + const legacy = buildPacket({ ...base, issue: { ...base.issue, body: 'просто описание, AC нет' } }); + assert.deepEqual(legacy.acceptance.map((a) => a.id), ['AC1', 'AC2'], 'без AC в теле — архивный файл'); + + const neither = buildPacket({ ...base, issue: { ...base.issue, body: 'ничего' }, specs: [] }); + assert.deepEqual(neither.acceptance, []); +});