Compare commits

...
Author SHA1 Message Date
Claude 18c9f8e77c fix(stairs): View shows the link pointer over a stair, not the move cursor (#693)
The Plan editor rule `.hp-stair.input-enabled .hp-stair-hit { cursor: move }`
also matched the View layer, which sets input-enabled only to receive
clicks. The hit area sits over the outline, so the link's pointer on the
group was never visible and every stair in View showed a drag it cannot do.

- src/stairs-view.ts: View stairs carry `hp-stair-view`.
- plan.styles.ts: `move` applies only without it; in View the hit area
  keeps the group's cursor — pointer on a link, the stage's otherwise.
- demo/smoke_stairs.mjs: computed cursors in View (link, no target) and
  in the Plan editor; the two View checks are red on the old code.
- test/stairs.test.mjs: the cascade without Chromium; mutant
  view-stair-cursor-move-again.
- docs/STAIRS.md, changelogs.

Issue: #693
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-29 21:52:52 +03:00
claude[bot] 674e589ad8 docs(reviews): индекс после сдвига каталога (#690)
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Waiting to run
Проверка (CI) / Классификация изменённых файлов (push) Waiting to run
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Blocked by required conditions
Проверка (CI) / HACS: валидация репозитория (push) Blocked by required conditions
Проверка (CI) / Hassfest: манифест интеграции (push) Blocked by required conditions
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Blocked by required conditions
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Blocked by required conditions
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Blocked by required conditions
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Blocked by required conditions
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Blocked by required conditions
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Blocked by required conditions
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Blocked by required conditions
Проверка (CI) / Бандл головы dev для стенда (push) Blocked by required conditions
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Blocked by required conditions
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Blocked by required conditions
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Blocked by required conditions
Проверка (CI) / Смоки: все шарды зелёные (push) Blocked by required conditions
Проверка (CI) / Golden-кадры против принятых эталонов (push) Blocked by required conditions
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Blocked by required conditions
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Blocked by required conditions
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Blocked by required conditions
Проверка (CI) / Доказательство выполненных проверок (push) Blocked by required conditions
Issue: #690
User-Visible: no
2026-09-29 06:27:42 +00:00
claude[bot] 26763c7ca4 docs: review document for #690
Issue: #690
User-Visible: no
2026-09-29 06:27:39 +00:00
Claude 52a56430ab process: an unproven smoke link runs the visual minimum; raster defects need a witness (#690)
The two remaining owner decisions of #690 and the legacy trivial text.

- scripts/smoke-select.mjs: VISUAL_MINIMUM, eight smokes of modes,
  layers and rendering (under a minute locally). An executable diff
  with no proven link now returns and prints it instead of only "the
  reviewer decides"; #687 missed smoke_modes that way (item 1').
- scripts/gate-small.mjs: `--smokes` runs the minimum with the
  selection.
- PROCESS §7.1 and AUTHOR.md: a raster, sharpness or compositing defect
  needs a witness red on the old code for the owner's symptom and the
  owner's confirmation in a real GPU browser (item 4).
- PROCESS §8, TESTING.md: the minimum in the smoke-select rule.
- scripts/task-packet.mjs: legacy `trivial` is product flow read as
  track:show (§5.1), not a short track without a spec.
- Tests; mutants visual-minimum-silent-again,
  visual-minimum-on-proven-link, gate-small-skips-visual-minimum;
  task-packet-trivial-is-product-flow retargeted.

Issue: #690
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-29 08:45:44 +03:00
claude[bot] 6e07313460 docs(reviews): индекс после сдвига каталога (#706)
Issue: #706
User-Visible: no
2026-09-29 05:04:40 +00:00
claude[bot] f903e93c83 docs: review document for #706
Issue: #706
User-Visible: no
2026-09-29 05:04:38 +00:00
Claude ae0e516af1 process: a rereview sets S7-code-review again instead of stripping it (#706)
The label step after integration ran one gh call
`--add-label "$TO" --remove-label "$FROM"`. For the rereview outcome
TO == FROM == S7-code-review, and gh added and removed the same label:
#699 was left without a status and no new round started (run
36491087708).

- scripts/status-label.mjs: the same label is removed and set again
  through relabel from process-reconcile (#555), so the labeled event
  starts the next round and a failed restore fails the step; a
  different label is still one call.
- _process.yml: the step calls the script.
- PROCESS.md: the exact-candidate rule names the relabel.
- test/status-label.test.mjs; mutants rereview-relabel-in-one-call and
  process-label-step-combined-again.

Issue: #706
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-29 07:19:05 +03:00
claude[bot] e7fca7b955 docs(reviews): индекс после сдвига каталога (#699)
Проверка (CI) / Предполёт: документация, провенанс, процесс (push) Canceled after 0s
Проверка (CI) / Классификация изменённых файлов (push) Canceled after 0s
Проверка (CI) / Переиспользование: это дерево уже проверено (push) Canceled after 0s
Проверка (CI) / HACS: валидация репозитория (push) Canceled after 0s
Проверка (CI) / Hassfest: манифест интеграции (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (1/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (2/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (3/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (4/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (5/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Мутанты по диффу (6/6): затронутые свидетели краснеют (push) Canceled after 0s
Проверка (CI) / Фронтенд: типы, юниты, мутанты, синхрон бандла (push) Canceled after 0s
Проверка (CI) / Бандл головы dev для стенда (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 1 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 2 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки в браузере (шард 3 из 3) (push) Canceled after 0s
Проверка (CI) / Смоки: все шарды зелёные (push) Canceled after 0s
Проверка (CI) / Golden-кадры против принятых эталонов (push) Canceled after 0s
Проверка (CI) / Перф-смок: бюджет времени кадра (push) Canceled after 0s
Проверка (CI) / Геометрия: TS/Python parity исполнена (push) Canceled after 0s
Проверка (CI) / Бэкенд: pytest в Home Assistant (push) Canceled after 0s
Проверка (CI) / Доказательство выполненных проверок (push) Canceled after 0s
Issue: #699
User-Visible: no
2026-09-28 22:39:36 +00:00
claude[bot] 48f6f2e6a7 docs: review document for #699
Issue: #699
User-Visible: no
2026-09-28 22:39:36 +00:00
claude[bot] c949e0bbc9 docs(reviews): индекс после сдвига каталога (#699)
Issue: #699
User-Visible: no
2026-09-28 22:39:36 +00:00
claude[bot] 5bb7197620 docs: review document for #699
Issue: #699
User-Visible: no
2026-09-28 22:39:36 +00:00
Claudeandclaude[bot] 6a8658cab9 docs(release): the candidate checklist lowers the ratchets (#699 r1)
CODE-REVIEW-699-r1 M1: `node scripts/ratchets.mjs tighten` was named only
by the warning `release:prerelease` prints at publication, when the
candidate commit is already made. The candidate checklist in
docs/DEVELOPMENT.md now runs it after `npm run bundle:release`, so the
caps come from the fresh dist/ and land in the candidate commit.

- test/ratchets.test.mjs pins the step and its order.
- Mutant release-runbook-forgets-tighten.

Issue: #699
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-28 22:39:36 +00:00
claude[bot] 258d1990c8 docs: review document for #699
Issue: #699
User-Visible: no
2026-09-28 22:39:36 +00:00
Claudeandclaude[bot] 2369c50607 process: the bundle CLI judges the beta ceiling before the budget (#699)
Validate on 3dd032d7 (run 36480911145): the mutant
initial-view-ceiling-unplugged survived. With the band over the ceiling,
ceiling + band (303 000) lies above the absolute INITIAL_VIEW_GZIP_BUDGET
(301 066), so every value the CLI test could feed went red on the budget
first and the ceiling check became unobservable.

- bundle-budget.mjs CLI: the initial View ceiling is judged before
  assertBundleBudget, so a growth over the band names the ratchet that
  caught it; the budget still stops anything the band lets through.
- The CLI test feeds ceiling + band + 1 and expects the band message.
- The mutant's anchor follows the moved lines.

Issue: #699
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-28 22:39:36 +00:00
Claudeandclaude[bot] c68d92f674 process: ratchets get a band over the beta ceiling (#699)
Two-sided ratchets with zero slack made parallel tasks conflict on shared
numbers, recompute them after every rebase and hit a ceiling because a
neighbour merged first (#689 after #691).

- Core lines (test/core-file-budget.test.mjs): a branch may grow up to
  CORE_BAND = 50 lines over the beta ceiling; shrinking no longer fails it.
- Bundle graphs (bundle-budget.mjs): initial View and lazy graphs fail only
  above ceiling + 2 000 B; below the ceiling is not a branch finding. The
  absolute INITIAL_VIEW_GZIP_BUDGET stays the wall.
- Monolith numbers (monolith-metrics.mjs, unused-locals-gate.mjs):
  METRIC_BANDS — 5 for delegates, port members and privates, 25 for host.
  refs, 2 000 B for dist/; a lower number is reported, not failed.
- Browser mutation guards: 200 is a guideline — mutation-gate --check warns
  above it instead of failing; every guard still needs its reason line.
- scripts/ratchets.mjs: `report [--warn]` and `tighten` — on the beta
  candidate the release manager sets every ceiling to the fact in one
  commit; release:prerelease prints loose ceilings as a warning.

Canon: PROCESS.md §3 (browser guards, monolith numbers) and §8 «Храповики»;
docs/TESTING.md.

Issue: #699
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-28 22:39:36 +00:00
claude[bot] 8bb43fd379 docs(reviews): индекс после сдвига каталога (#700)
Issue: #700
User-Visible: no
2026-09-28 22:39:11 +00:00
claude[bot] 310bc6e4f8 docs: review document for #700
Issue: #700
User-Visible: no
2026-09-28 22:39:08 +00:00
claude[bot]andClaude 1aa52d2107 docs: review document for #700
Issue: #700
User-Visible: no
2026-09-29 01:22:14 +03:00
Claude f6e317d871 process: a failed read of open issues never files a duplicate (#700 r1)
CODE-REVIEW-700-r1 Medium: `gh issue list … || true` turned a failed read
into an empty answer, and the step went on to gh issue create — a second
[workflow-sync] issue next to the open one on every network or rate-limit
failure. A failed read now warns and exits 0; creating stays reserved for
«read succeeded, nothing open».

Mutant workflow-sync-issue-duplicated-on-read-failure.

Issue: #700
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-29 01:22:14 +03:00
claude[bot]andClaude a005aae5b6 docs: review document for #700
Issue: #700
User-Visible: no
2026-09-29 01:22:14 +03:00
Claude e45bc87c6d process: preflight does not fail a task branch for foreign causes (#700)
11 of 85 returns in #600–#691 were the thin-workflow mirror check, and any
push could turn red because a foreign site behind a docs link was down.

- validate.yml preflight: on refs/heads/issue/* the workflow_sync mismatch
  is a warning in the summary, not a failed verdict; push to dev, the beta
  candidate and the release keep it red.
- On push to dev a mismatch opens one owner issue titled [workflow-sync]
  (or comments on the open one), like the nightly mutation gate (#472);
  preflight gets issues: write for that.
- check-docs --external=warn: external link failures become warnings; the
  docs step passes it on task branches only.

Canon: PROCESS.md §10.4 («Workflow из ветки по умолчанию»).

Issue: #700
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-29 01:22:14 +03:00
claude[bot] e1700757de docs(reviews): индекс после сдвига каталога (#701)
Issue: #701
User-Visible: no
2026-09-28 21:51:03 +00:00
claude[bot] 1606407107 docs: review document for #701
Issue: #701
User-Visible: no
2026-09-28 21:51:00 +00:00
Claude 8dcc1cad4e docs(process): канон без противоречий, вход автора короче (#701)
Сверка PROCESS.md, ролевых выжимок, AGENTS.md, TESTING.md, CONTRIBUTING.md
и скриптов по 26 найденным расхождениям (D1–D26): трейлеры по классам
изменений, gate:small как единственный источник состава, пороги ревью,
путь реестра мутантов, golden по ci:golden, порядок чтения промпта ревью.

- scripts/change-classes.mjs: классы A/B/C/D — один модуль для
  process-gate и проверки трейлеров.
- commit-msg: коммит только с файлами класса C (документация) трейлеров
  не требует; указанные трейлеры по-прежнему проверяются.
- Маршрут автора без docs/STATUS.md: 5345 → 4703 слова.
- Промпт ревью читает SCOPE → AGENTS → REVIEWER, как ROUTES.reviewer.

Issue: #701
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-29 00:30:04 +03:00
claude[bot] 224d0106fd docs(reviews): индекс после сдвига каталога (#702)
Issue: #702
User-Visible: no
2026-09-28 21:18:49 +00:00
claude[bot] ee3aa068fc docs: review document for #702
Issue: #702
User-Visible: no
2026-09-28 21:18:49 +00:00
Claudeandclaude[bot] 19dc61db15 process: the merge deletes the task branch it merged (#702)
370 merged issue/* branches sat on origin; the branch list stopped meaning
anything and an agent looking a branch up by number could take a stale one.

- merge-candidate.mjs: after a successful push to dev the task branch is
  deleted with --force-with-lease on the tip the merge saw last — the
  candidate published into the branch, or the material on fast-forward
  (the index commit lives only in dev). A commit that landed after the
  merge keeps the branch, and the merge comment says so; a failed delete
  never undoes the merge. Failed, stale and conflicting merges keep it.
- The one-time cleanup of the already merged branches is not in this
  commit: the list goes to the owner first.

Canon: PROCESS.md §10.4 (exact-candidate merge).

Issue: #702
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-28 21:18:49 +00:00
claude[bot] c66884b7f5 docs(reviews): индекс после сдвига каталога (#698)
Issue: #698
User-Visible: no
2026-09-28 21:01:38 +00:00
claude[bot] 59bf216b60 docs: review document for #698
Issue: #698
User-Visible: no
2026-09-28 21:01:38 +00:00
Claudeandclaude[bot] 6ab791e348 docs(process): name today's monolith tolerance in the rebase rule (#698 r1)
CODE-REVIEW-698-r1 Medium: the canon said the merged monolith numbers are
judged «by the band test (#699)», but #699 is not merged — today
compareWithBaseline judges five numbers exactly and only dist/ bytes with
a band. The paragraph now says so: dev's side of the baseline turns the
candidate's Validate red when the task itself changed those numbers — the
same return to the author as before, after Validate instead of before the
review; the band for all six numbers is #699. The comment on UPSTREAM_WINS
says the same.

Issue: #698
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-28 21:01:38 +00:00
claude[bot] e6dfa9a752 docs: review document for #698
Issue: #698
User-Visible: no
2026-09-28 21:01:38 +00:00
Claudeandclaude[bot] 5986332eda process: the rebase merges what two tasks never disagree on (#698)
14 of 48 returns in #600–#691 were rebase or merge conflicts on shared
files where the two edits do not contradict each other.

- .gitattributes: docs/CHANGELOG.md and docs/CHANGELOG.ru.md use the
  built-in merge=union driver — both tasks' lines in ## Unreleased survive
  a rebase, a merge and git merge-tree (#696's clean-merge test) without a
  stop.
- rebase-generated.mjs: UPSTREAM_WINS — on a conflict in
  scripts/monolith-baseline.json the rebase takes dev's side; the band test
  on the candidate's Validate judges the merged tree (#699). Any other
  conflicting path aborts exactly as before, with the full list.
- merge-candidate.mjs: the candidate's patch-id excludes the changelogs and
  the monolith baseline next to docs/reviews, so a neighbour's line next to
  the task entry does not re-send a green task to review.
- screenshots.json needs nothing: after #697 task branches do not commit it.

Canon: PROCESS.md, the rebase paragraph of the review index (#643).

Issue: #698
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-28 21:01:38 +00:00
claude[bot] 13054e246e docs(reviews): индекс после сдвига каталога (#697)
Issue: #697
User-Visible: no
2026-09-28 20:59:40 +00:00
69 changed files with 3396 additions and 310 deletions
+6
View File
@@ -12,3 +12,9 @@
*.zip binary
*.woff binary
*.woff2 binary
# #698: записи двух задач в `## Unreleased` друг другу не противоречат — при
# слиянии и ребейзе git объединяет обе стороны сам (встроенный драйвер union),
# и параллельные задачи не возвращаются в S6 из-за соседней строки ченджлога.
docs/CHANGELOG.md merge=union
docs/CHANGELOG.ru.md merge=union
+9 -6
View File
@@ -1152,15 +1152,15 @@ jobs:
ограничитель: «features are built, improved and accepted only
if they serve a job listed here». Первый вопрос к задаче —
какую строку Core user jobs она закрывает.
2. docs/process/REVIEWER.md — обязанности ревьюера: позиция,
2. AGENTS.md — карта пакета, правило №1, классы изменений, треки,
трейлеры и ожидание вердикта; сами правила — по его ссылкам.
3. docs/process/REVIEWER.md — обязанности ревьюера: позиция,
ревью ТЗ, код-ревью, объём гейтов, повторный раунд, находки и
вердикт. Раздел PROCESS.md по ссылке открывай, когда пункт
касается твоего решения; при расхождении прав PROCESS.md. Если
файла в материале нет — читай PROCESS.md §2.4, §2.7, §2.10,
§4, §7.2, §8, §12. Задача правит сам конвейер, гейты или
процесс — PROCESS.md целиком, §10 в первую очередь.
3. AGENTS.md — карта пакета, правило №1, классы изменений, треки,
трейлеры и ожидание вердикта; сами правила — по его ссылкам.
4. Тело issue #${{ github.event.issue.number }} и все комментарии.
5. Если меняется видимое поведение — docs/USER-GUIDE.ru.md:
терминология интерфейса берётся оттуда, а не изобретается.
@@ -1768,10 +1768,13 @@ jobs:
# когда кандидат не слит (конфликт, красный Validate, изменившийся
# patch-id, ушедший dev). Без исхода от скрипта — как раньше: S6.
TO: ${{ (needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.to != '') && steps.merge.outputs.to || ((needs.guard.outputs.stage == 'code' && steps.decide.outputs.green == 'true' && steps.merge.outputs.merged != 'true') && 'S6-in-progress' || steps.decide.outputs.to) }}
# #706: исход `rereview` возвращает задачу в ту же метку. Одним вызовом
# `gh` она добавлялась и тут же снималась — задача оставалась без
# статуса. Скрипт снимает и ставит её заново, чтобы событие `labeled`
# запустило новый заход.
run: |
gh issue edit "$NUM" --repo "${{ github.repository }}" \
--add-label "$TO" --remove-label "$FROM"
echo "$FROM -> $TO"
node scripts/status-label.mjs --repo="${{ github.repository }}" \
--issue="$NUM" --from="$FROM" --to="$TO"
- name: Сводка длительности стадий
if: always()
+61 -5
View File
@@ -53,12 +53,13 @@ jobs:
runs-on: ubuntu-24.04
timeout-minutes: 15
# `actions: read` — чтение списка прогонов Validate (#388), `issues: read` —
# проверка 8 процессного гейта. Права перечислены явно, потому что job
# обращается к API сверх содержимого репозитория.
# проверка 8 процессного гейта, `issues: write` — одно issue о расхождении
# зеркала workflow на push в dev (#700). Права перечислены явно, потому что
# job обращается к API сверх содержимого репозитория.
permissions:
contents: read
actions: read
issues: read
issues: write
steps:
# `blob:none` при полной истории (#345): этой job нужны сообщения
# коммитов, трейлеры и ИМЕНА изменённых файлов, а не содержимое старых
@@ -81,19 +82,24 @@ jobs:
id: docs
continue-on-error: true
env:
REF: ${{ github.ref }}
EVENT_NAME: ${{ github.event_name }}
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
FULL_INPUT: ${{ inputs.full }}
# #697: на ветке задачи `Release:` строгий режим не включает.
REF_NAME: ${{ github.ref_name }}
run: |
# #700: на ветке задачи упавший чужой сайт — предупреждение, а не
# красный preflight; push в dev, кандидат и релиз судят ссылки строго.
external=--external
case "$REF" in refs/heads/issue/*) external=--external=warn ;; esac
# #586: CLI отдаёт ОДИН ответ. Прежде здесь сравнивался со строкой
# `heavy=true` весь вывод `--heavy`, а он двухстрочный: в `$(…)`
# строки схлопываются через пробел, сравнение не совпадало никогда,
# и строгий режим не включился ни на одном кандидате.
mode=$(node scripts/classify-changes.mjs --screenshots-mode)
echo "скриншоты документации: режим $mode"
node scripts/check-docs.mjs --external --screenshots=$mode
node scripts/check-docs.mjs "$external" --screenshots=$mode
# #635: `docs/reviews/INDEX.md` — снимок каталога ревью; расхождение с
# каталогом — невидимые через индекс документы (r2 #635 H1). Гейт стоит
@@ -140,6 +146,39 @@ jobs:
done
exit $status
# #700: расхождение зеркала на dev — одно открытое issue владельцу, как у
# ночного мутационного гейта (#472): чинит его тот, кто зеркалит в main, а
# не автор задачи, чья ветка ни при чём. Сбой API — предупреждение: шаг
# сообщает, а не судит.
- name: "Расхождение зеркала на dev — issue владельцу"
if: github.event_name == 'push' && github.ref == 'refs/heads/dev' && steps.workflow_sync.outcome == 'failure'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
marker="[workflow-sync]"
# Список не прочитан — нового issue нет: иначе сбой сети или
# рейт-лимит плодил бы дубликат к уже открытому (r1 #700).
if ! existing=$(gh issue list --repo "$REPO" --state open --search "\"$marker\" in:title" \
--json number,title --jq '[.[] | select(.title | startswith("[workflow-sync]"))][0].number // empty'); then
echo "::warning::открытые issue не прочитаны — новое не заводится, чтобы не завести дубликат"
exit 0
fi
if [ -n "$existing" ]; then
gh issue comment "$existing" --repo "$REPO" --body "Расхождение повторилось: $RUN_URL" \
|| echo "::warning::комментарий в #$existing не оставлен"
exit 0
fi
cat > /tmp/workflow-sync.md <<EOF
Тонкие вызывающие workflow в \`main\` и \`dev\` различаются. Прогон: $RUN_URL
Тонкий файл исполняется из ветки по умолчанию, поэтому правку триггеров, входов или прав нужно зеркалить в \`main\` (PROCESS.md §10.4, #623). На ветках задач это предупреждение (#700), на push в \`dev\` — красный preflight, пока зеркало не выровнено.
EOF
gh issue create --repo "$REPO" --title "$marker тонкие workflow в main и dev различаются" \
--label infra --label process --body-file /tmp/workflow-sync.md \
|| echo "::warning::issue о расхождении зеркала не заведено"
# Оба гейта ниже судят САМ диапазон коммитов, а не объём проверок, и до
# #388 брали его от головы предыдущего пуша. Прогон предыдущего пуша
# штатно отменяется следующим (concurrency), и тогда его коммиты не судит
@@ -220,6 +259,7 @@ jobs:
PROCESS_GATE: ${{ steps.process_gate.outcome }}
ACTION_PINS: ${{ steps.action_pins.outcome }}
REVIEWS_INDEX: ${{ steps.reviews_index.outcome }}
REF: ${{ github.ref }}
run: |
fail=0
check() {
@@ -231,9 +271,25 @@ jobs:
fail=1
fi
}
# #700: причина, к которой ветка задачи отношения не имеет, — в сводку
# предупреждением; на dev, кандидате и релизе она красит как прежде.
advise() {
if [ "$2" = "success" ]; then
echo "ok $1"
else
echo "::warning::$1 ($2) — на ветке задачи предупреждение (#700)"
echo "- $1: $2 — предупреждение, чинит релиз-менеджер (#700)" >> "$GITHUB_STEP_SUMMARY"
fi
}
task_branch=false
case "$REF" in refs/heads/issue/*) task_branch=true ;; esac
echo "### Предполётные проверки" >> "$GITHUB_STEP_SUMMARY"
check "документация" "$DOCS"
check "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC"
if [ "$task_branch" = "true" ]; then
advise "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC"
else
check "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC"
fi
check "провенанс коммитов" "$PROVENANCE"
check "процессный гейт" "$PROCESS_GATE"
check "пины сторонних Actions" "$ACTION_PINS"
+6 -3
View File
@@ -29,7 +29,9 @@ leak interactions into View. For work that changes visible behaviour, also read
route and `test/entry-cost.test.mjs` keeps this list equal to its routes:
- author (analysis, spec, implementation, infrastructure): `docs/SCOPE.md` →
`AGENTS.md` → `docs/process/AUTHOR.md` → `docs/STATUS.md`;
`AGENTS.md` → `docs/process/AUTHOR.md`, then the task packet
(`node scripts/task-packet.mjs --issue NN`); the status snapshot
(docs/STATUS.md) only when resuming a session or preparing a release;
- reviewer (spec or code): `docs/SCOPE.md` → `AGENTS.md` →
`docs/process/REVIEWER.md`, then the issue body and its comments;
- changing the pipeline, the gates or the process itself: `docs/SCOPE.md` →
@@ -100,8 +102,9 @@ batched comment with a proposed default for each question and `blocked` on top o
## Commits and branches
Hooks install themselves on `npm ci` (`prepare` → `scripts/install-hooks.mjs`);
`git config core.hooksPath` must print `.githooks`. Every non-merge commit
carries **terminal** trailers:
`git config core.hooksPath` must print `.githooks`. Every non-merge commit that
touches anything outside class C (docs) carries **terminal** trailers; a
docs-only commit needs none (`PROCESS.md` §3 п.10, #701):
```text
Issue: #123
+5 -4
View File
@@ -94,12 +94,11 @@ issue, but they do not replace it or maintain a separate checklist.
```bash
git clone --filter=blob:none https://github.com/Matysh/houseplan-card && cd houseplan-card
npm ci # frontend toolchain
npm ci # frontend toolchain; `prepare` installs .githooks
npm run typecheck # tsc --noEmit (strict)
npm test # node:test — pure logic, i18n parity, tap-action security
npm run build # tsc + rollup → dist/houseplan-card.js
pip install pytest voluptuous && python -m pytest tests_backend -q # pure backend tests
npm install # also installs .githooks through the prepare script
pip install -r tests_backend/requirements.txt && python -m pytest tests_backend -q # CI pins, HA harness included
```
### Why `--filter=blob:none` (#345)
@@ -127,7 +126,9 @@ Linux CI or WSL (`bash scripts/wsl-setup.sh --verify`). Without an importable
## Ground rules
- **Docs in the same commit**: CHANGELOG entry for user-visible changes;
`docs/STATUS.md` for state changes; `docs/DEVELOPMENT.md` for new gotchas.
`docs/ARCHITECTURE.md` for changes to the data model, WS API or coordinate
system; `docs/STATUS.md` for state changes; `docs/DEVELOPMENT.md` for new
gotchas. A docs-only commit needs no trailers (`PROCESS.md` §3 п.10).
- Every UI string goes through `src/i18n/<lang>.json`; follow the
[Translations](#translations) flow for registry and backend parity.
- The committed bundle changes only in a release candidate: `npm run
+105 -33
View File
@@ -7,8 +7,10 @@
> агенты/сессии · любой агент может взять любую роль · инфраструктурные задачи
> входят в общий флоу сразу на `S7-code-review`.
>
> **Область действия:** обязателен для владельца и для любого агента. Читается
> сразу после `docs/SCOPE.md` и `AGENTS.md`, до `docs/STATUS.md`. Живёт в
> **Область действия:** обязателен для владельца и для любого агента. Целиком
> его читает тот, кто правит конвейер, гейты или сам процесс, — сразу после
> `docs/SCOPE.md` и `AGENTS.md`, до `docs/STATUS.md`; автор и ревьюер входят через
> ролевые конспекты ниже и открывают раздел канона по ссылке (#634, #701). Живёт в
> репозитории: до августа 2026 канон лежал только в папке владельца, и свежий клон
> его не содержал вовсе.
>
@@ -24,8 +26,9 @@
> метках и больше нигде: Project v2 не используется. При расхождении
> документации с GitHub побеждает GitHub. Этот файл — единственный полный канон
> процесса в репозитории; `AGENTS.md` — его короткое обязательное резюме, а
> внешний `CODEX-RUNBOOK.md` — только маршрутизатор к канону и историческим
> инструкциям. Текущие версии, состояние конкретных issue, runtime pins и списки
> внешние `CODEX-RUNBOOK.md` и `CLAUDE.md` папки владельца — только маршрутизаторы
> к канону и историческим инструкциям: правил в них нет, и в замер цены входа
> (`scripts/entry-cost.mjs`) они не входят — CI их не видит (#701). Текущие версии, состояние конкретных issue, runtime pins и списки
> jobs не копируются в производную прозу: они читаются из своих исполняемых
> источников.
> При расхождении этого документа с `.github/workflows/*.yml` и `scripts/*`
@@ -201,8 +204,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
«Взял: <роль> · сессия <id> · ветка `issue/<NN>-<slug>`».
- **WIP-лимиты:** не более **1** issue в «В разработке» на исполнителя, не более
**3** одновременно на цикл релиза, не более **2** в «Код-ревью».
- **Трассируемость:** ветка `issue/<NN>-<slug>`; каждый коммит несёт трейлеры
`Issue: #<NN>` и `User-Visible: yes|no`.
- **Трассируемость:** ветка `issue/<NN>-<slug>`; каждый коммит с файлами классов A, B или D несёт трейлеры
`Issue: #<NN>` и `User-Visible: yes|no`; коммит только из документации — без
трейлеров (§3 п.10, #701).
- **Автотесты — часть реализации, а не отдельная фаза.** Каждый AC, помеченный
`unit`/`backend`/`smoke`/`golden`, получает свою проверку здесь же.
«Тестирование вне жизненного цикла» означает отсутствие фазы ручного
@@ -251,7 +255,7 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
а два теста были записаны в закрытие coverage-ratchet под именами, обещавшими
то, чего они не проверяли (#430).
Мутант в `scripts/mutation-gate.mjs` обязателен, когда защита живёт в
Мутант в реестре `scripts/mutation-registry.mjs` обязателен, когда защита живёт в
продуктовом коде и проверяется дорогим гейтом (смок, бэкенд, golden): там
ревьюер не воспроизведёт отрицательный прогон второй раз. Для чистых юнитов
достаточно прогона со снятой защитой, приведённого в документе.
@@ -261,8 +265,9 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
DOM/CSS paint, измеренной геометрии, trusted pointer/lifecycle или browser
wall-time, а id — попасть в размеченный реестр
`docs/testing-notes/mutation-browser-guards.md`. `mutation-gate --check`
показывает число browser guards против лимита 200, краснеет при росте выше
лимита и предупреждает о любом id без browser-обоснования; ревьюер проверяет
показывает число browser guards против ориентира 200 и предупреждает — не
краснеет (#699) — при росте выше него и о любом id без browser-обоснования;
удалять чужой мутант ради числа не нужно; ревьюер проверяет
не только наличие строки, но и невозможность более дешёвого `node --test`.
Считаются **защитные AC без названного свидетеля**, а не мутанты на
@@ -299,9 +304,10 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
(`scripts/monolith-metrics.mjs`: делегаты, члены порта, `host.`, приватные
члены порта и харнесса, байты `dist/`), база — `scripts/monolith-baseline.json`;
гейт `npm run lint:unused` (в `gate:small` и Validate после сборки) красит
рост любого из них и любой мёртвый код по `noUnusedLocals` вне порта и
харнесса. Снижение фиксируется тем же коммитом
(`node scripts/unused-locals-gate.mjs --update`); рост — только с записью в
рост любого из них сверх полосы (#699: делегаты, члены и приватные порта и
харнесса — 5, `host.` — 25, байты `dist/` — 2 000) и любой мёртвый код по
`noUnusedLocals` вне порта и харнесса. Снижение ветку не красит — базу до
факта опускает бета (§8, «Храповики»); рост сверх полосы — только с записью в
issue задачи и правкой базы в том же коммите.
- **Смок входит в сценарий через публичную поверхность (#629).** DOM с
контрактными хуками, события HA и фикстуры, тестовый фасад `window.__hpTest`
@@ -385,16 +391,26 @@ S1-new → S2-analysis → S3-spec → S4-spec-review ⟲ → S5-ready →
fast-forward (`--commit-if-stale`, коммит класса C) — и публикацией документа ревью ТЗ
прямо в `dev`. В ветке задачи индекс не пересобирается — ни при приведении к
dev, ни при публикации документа код-ревью: иначе две параллельные задачи
конфликтуют на нём по построению. Руками не правится. Конфликт ребейза, в котором **все** пути —
конфликтуют на нём по построению. Сам `INDEX.md` руками не правится никогда. Конфликт ребейза, в котором **все** пути —
`INDEX.md`, отказом не считается (#643): `scripts/rebase-generated.mjs`
пересобирает индекс по каталогу на остановке и продолжает ребейз — так делают
приведение к dev, слияние кандидата и авторский `rebase-on-dev.mjs`; индекс
вместе с любым другим путём — прежний отказ с перечнем файлов. Шаг Validate
вместе с любым другим путём — прежний отказ с перечнем файлов. Ещё два общих
файла не конфликтуют по смыслу (#698): записи ченджлогов в `## Unreleased`
объединяет встроенный драйвер `merge=union` (`.gitattributes`), а на конфликте в
`scripts/monolith-baseline.json` ребейз берёт сторону `dev` — числа
объединённого дерева судит гейт связности монолита (`npm run lint:unused`) на
Validate кандидата. Сегодня пять чисел исходника он судит точно, байты `dist/` —
с полосой 2 000 Б; поэтому сторона `dev` в базе краснеет, если задача сама
меняла эти числа, — это прежний возврат автору, только после Validate, а не до
ревью. Полоса у всех шести чисел — #699. Ни ченджлоги, ни база не входят в
patch-id кандидата слияния: вердикт к работе задачи остаётся в силе. Шаг Validate
«индекс ревью совпадает с каталогом» красит push в `dev`, где `INDEX.md`
расходится с каталогом (на issue-ветках не судится: их переписывает конвейер).
Правка `docs/reviews/`
руками — перенос в `legacy/`, удаление — сопровождается
`node scripts/reviews-index.mjs` в том же коммите. Прежде чем брать
Ручная правка каталога
`docs/reviews/` — перенос документов в `legacy/`, удаление — сопровождается
пересборкой индекса `node scripts/reviews-index.mjs` в том же коммите: индекс
меняет генератор, а не рука. Прежде чем брать
задачу по подсистеме, стоит прочитать её строки в индексе: что находили и чем
закрывали — там, а не в тысяче файлов. Уроки, пережившие свою задачу,
собираются в `docs/LESSONS.md` с датой и ссылкой на источник.
@@ -439,8 +455,10 @@ dev, ни при публикации документа код-ревью: ин
1. **Никаких изменений в код, если нет issue** и он не помечен «Готово к
разработке» или дальше.
2. **Issue не может быть взят в разработку**, пока у него нет ТЗ с зелёным ревью,
пронумерованных AC с указанием доказательства и назначенного исполнителя.
2. **Issue не может быть взят в разработку**, пока у него нет ТЗ в объёме трека
(§5: на `track:ask` — с зелёным ревью ТЗ; на `show` — до трёх AC; на `ship` —
строка «что меняется и чем проверить»), доказательства для каждого AC и
назначенного исполнителя. Инфраструктурная задача ТЗ не пишет (§1).
3. **Issue не может быть взят дважды.** Занятие фиксируется назначением, меткой и
комментарием с именем ветки. У одного исполнителя одновременно не более одного
issue в разработке.
@@ -460,14 +478,17 @@ dev, ни при публикации документа код-ревью: ин
правка. Блокирующая находка отправляет текущий issue в «Заблокировано».
10. **Каждый коммит класса A и B несёт трейлер `Issue: #NN`**, ветка называется
`issue/NN-slug`, а `User-Visible: yes` требует правок в **обоих** changelog в
том же коммите. После `cherry-pick -x` служебная строка `(cherry picked
том же коммите. Документационный коммит — только файлы класса C — трейлеров
не требует (#701, как `skip issue` у CPython); хук `commit-msg`, CI и
`process-gate` судят одинаково. Коммит класса D несёт `Release:` (п.12). После `cherry-pick -x` служебная строка `(cherry picked
from ...)` должна оставаться выше финального блока трейлеров: перед push
проверяем порядок через `git show -s --format=full HEAD`.
11. **Документация — в том же коммите, что поведение.** Отдельным «допишу потом»
коммитом документация не бывает.
12. **Сгенерированное не коммитится само по себе.** Только релизный промоушен или
принятие эталонов с доказательством ревью: URL Linux CI run либо хеш
аттестованного WSL-артефакта.
принятие эталонов с доказательством ревью: трейлер `Release:` и ровно один
источник — URL Linux CI run (`Baseline-Reviewed:`) либо хеш аттестованного
WSL-артефакта (`Baseline-Reviewed-Local:`), §10.1.
13. **Golden-эталоны принимаются только** `npm run golden:accept -- --reviewed` по
полному Linux-артефакту: либо GitHub CI, либо `npm run golden:wsl:capture` в
WSL/ext4 с clean опубликованным SHA и машинно-проверяемым паспортом. Второй
@@ -507,8 +528,9 @@ dev, ни при публикации документа код-ревью: ин
- **Заход и цикл — разные величины.** Заход — сколько раз ревью отработало; он
виден в имени документа (`-r1`, `-r2`, …) и нужен, чтобы два документа не
затёрли друг друга. Цикл — единица бюджета §4. Заходов законно бывает больше,
чем циклов, поэтому порог проверки 7 в `scripts/process-gate.mjs` выше лимита
циклов (шесть документов = четыре цикла плюс два ребейза).
чем циклов, поэтому порог проверки №7 в `scripts/process-gate.mjs`
(`REVIEW_DOC_LIMIT` — шесть документов одного вида на issue) выше лимита
циклов: четыре цикла плюс два ребейза.
- Метка `review-4` ставится, когда исчерпан **бюджет циклов**; конвейер снимать
её не вправе — это решение владельца. Если бюджет пересчитан и оказался ниже
лимита, конвейер сообщает пересчёт, но метку не трогает.
@@ -619,7 +641,7 @@ dev, ни при публикации документа код-ревью: ин
| Аналитик | разбор, оценки, поверхности | окончательно ставить приоритет |
| Автор ТЗ | раздел `## ТЗ` в теле issue | ревьюить своё ТЗ |
| Ревьюер ТЗ | `docs/reviews/SPEC-REVIEW-NN-rN.md` | править ТЗ вместо автора |
| Разработчик | код, автотесты, документация, changelog | ревьюить свой код, принимать golden |
| Разработчик | код, автотесты, документация, changelog | ревьюить свой код; принимать golden — кроме сдвига своей задачи с меткой `ci:golden` по §3 п.13 (§8, #697) |
| Ревьюер кода | `docs/reviews/CODE-REVIEW-*-rN.md`, проверка AC | править продуктовый код |
| Релиз-менеджер | пре-релиз, стабильный релиз, закрытие issue | добавлять код в релизный коммит |
| Владелец | приоритет, ценность, скоуп, отклонение, арбитраж, хотфикс | — |
@@ -668,6 +690,11 @@ issue #NN
которое не может ответить на эти два вопроса, описывает работу, а не изменение
продукта.
**Дефект растра, резкости или композитинга** — на любом треке (#690): до кода
нужен свидетель, красный на старом коде именно по симптому владельца, а в AC —
подтверждение владельца в реальном GPU-браузере. Headless-доказательства мало:
#685 доказал правку в headless Linux и не устранил симптом, его откатил #689.
**Размытое место не додумывается, а выносится владельцу.** Догадка, записанная
как факт, — худший вид дефекта: она проходит ревью, потому что выглядит решением.
@@ -724,9 +751,16 @@ issue #NN
## 8. Гейты
**Локальный гейт перед выходом из «В разработке»** — минимальный набор,
покрывающий изменённые поверхности (действующее правило владельца):
покрывающий изменённые поверхности (действующее правило владельца).
Обязательную часть исполняет одна команда, `npm run gate:small`
(`scripts/gate-small.mjs` — единственный источник её состава, #701): сборка с
typecheck, юниты, целостность и бюджет бандла, `no-new-any`, запрет синхронного
чтения layout в render, запрет записи смоков в приватное состояние,
`lint:unused` и вывод `smoke-select`. Команды ниже — то же самое по отдельности
плюс то, что по диффу и AC:
```
npm run gate:small # обязательная часть одной командой
npx tsc --noEmit
npm test
npm run build && node scripts/bundle-policy.mjs --verify HEAD
@@ -762,7 +796,10 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
дерева; прогон всех уместен только когда задача задевает всё; какие относятся к
диффу, печатает
`node scripts/smoke-select.mjs --base origin/dev --head HEAD`, и его вывод
прикладывается к ревью вместе с решением по каждой строке), `golden:verify` при метке `ci:golden`, `pytest tests_backend` при правках в Python, performance-профили при
прикладывается к ревью вместе с решением по каждой строке; если связь не
доказана, он печатает визуальный минимум — восемь смоков режимов, слоёв и
отрисовки, локально меньше минуты, — и автор гоняет его до `S7`, `gate:small --
--smokes` делает это сам, #690), `golden:verify` при метке `ci:golden`, `pytest tests_backend` при правках в Python, performance-профили при
названном в AC влиянии. **Полные наборы — предрелизный гейт, а не гейт ревью.**
Скриншоты снимаются **только** каноническим прогоном в CI — `beta-derived.yml`
@@ -770,7 +807,8 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
(`workflow_dispatch`, только артефакт) с приёмкой вручную: `npm run docs:accept --
--reviewed --from=<распакованный артефакт>` (#246). Ручной путь остаётся
релиз-менеджеру, если бот недоступен. Съёмка на своей машине даёт байтово другой PNG при том же
кадре, и набор из «не того» браузера переписывает все десять файлов без единого
кадре, и набор из «не того» браузера переписывает все кадры (их число — в
`demo/docs/screenshots.mjs`) без единого
содержательного изменения. Приёмка отказывает, если кандидат снят не с этого
дерева, не тем капчуром, не называет свой Chromium или неполон; коммит бота
проверяет релиз-менеджер, коммит ручной приёмки делает человек.
@@ -809,6 +847,22 @@ npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs \
Строгая свежесть скриншотов по-прежнему обязательна на кандидате беты
(`publish-prerelease.yml`, `check-docs --screenshots=strict`).
**Храповики — полоса над потолком беты** (#699, решение владельца 2026-09-28).
Строки двух ядер (`test/core-file-budget.test.mjs`), gzip стартового и ленивых
графов (`scripts/bundle-budget.mjs`) и числа связности монолита
(`scripts/monolith-baseline.json`) судятся одним правилом: потолок — факт
последней беты; ветка задачи краснеет, только если вышла выше потолка больше
чем на полосу (ядро — 50 строк, графы и `dist/` — 2 000 Б, числа монолита — по
`METRIC_BANDS`), а снижение её не красит. Прежде храповики были двусторонними с
нулевым запасом: параллельные задачи конфликтовали на общих числах, пересчитывали
их после ребейза и упирались в потолок, потому что перед ними влили чужую (#689
после #691). Вторая сторона храповика живёт на бете: релиз-менеджер на
кандидате выполняет `node scripts/ratchets.mjs tighten` — потолки опускаются
(и поднимаются на принятый линией рост) до факта одним коммитом вместе с
кандидатом; `npm run release:prerelease` печатает рыхлые потолки
предупреждением. Абсолютный бюджет стартового графа
(`INITIAL_VIEW_GZIP_BUDGET`) остаётся стеной.
**Перф-смок в Validate зависит от диффа** (#473). Два glow-профиля
гоняются всегда; при правке `src/iso-*` добавляется `large-house-isometric-v1`,
при правке `src/live-*`, `src/render-*`, `houseplan-render-lifecycle.ts`,
@@ -929,7 +983,8 @@ Performance зелёные на точном SHA, плюс зелёный E2E н
включая свежий контейнер облачного агента, без отдельного шага в инструкции.
- **`commit-msg`** — есть, работает. Отклоняет коммит без терминального
`Issue: #NN`, требует ровно один `User-Visible: yes|no`, а для коммитов,
`Issue: #NN`, требует ровно один `User-Visible: yes|no` (коммит только из
файлов класса C — без трейлеров, §3 п.10, #701), а для коммитов,
трогающих `demo/golden/baselines/**`, — `Release:` плюс ровно один источник:
`Baseline-Reviewed: <URL GitHub run>` либо
`Baseline-Reviewed-Local: sha256:<хеш аттестации>`. Локальный хеш обязан
@@ -982,7 +1037,9 @@ Performance зелёные на точном SHA, плюс зелёный E2E н
`Baseline-Reviewed: <ссылка на прогон CI>` либо
`Baseline-Reviewed-Local: sha256:<хеш аттестации>`;
6. релизный коммит не содержит изменений в `src/` и `custom_components/**/*.py`;
7. документов ревью на один issue не больше четырёх (`-r1`…`-r4`).
7. документов ревью одного вида (`SPEC-REVIEW`, `CODE-REVIEW`) на один issue
не больше шести (`-r1`…`-r6`, `REVIEW_DOC_LIMIT`): четыре цикла плюс два
ребейза (§4).
С токеном GitHub:
@@ -1003,6 +1060,11 @@ Validate стартует от этого push и успевает прочит
документ ревью: он ложится в ветку задачи, пока та в `S4-spec-review` или
`S7-code-review`, то есть заведомо вне рабочего множества.
**Инфраструктурный диапазон статуса не требует** (#562, §1): если в диапазоне нет
ни одного файла класса A, задача ещё не вошла в поток — она войдёт в него сразу на
`S7-code-review`, — и отсутствие S-метки не отказ. Признак механический, по
диффу, а не по метке `infra`.
**При продвижении в `main` не перепроверяются коммиты, уже достижимые из
prerelease-тега.** После выпуска беты их issue по §2.8 должны быть закрыты, а
stable fast-forward снова включает эти коммиты в диапазон `old-main..candidate`.
@@ -1089,7 +1151,12 @@ Matysh/houseplan-card/.github/workflows/_<имя>.yml@dev` с `secrets: inherit`
триггеры, входы ручного запуска или потолок прав; тогда он зеркалится в
`main`, и preflight `workflow_sync` в `validate.yml` держит копии равными —
сверяются ровно эти шесть файлов, список держит
`test/default-branch-workflows.test.mjs`. `performance.yml` в список не входит:
`test/default-branch-workflows.test.mjs`. Расхождение красит push в `dev` и
заводит одно issue владельцу (`[workflow-sync]`), а на ветке задачи —
предупреждение в сводке (#700): к её изменению оно отношения не имеет, и чинит
его тот, кто зеркалит в `main`. Так же судятся внешние ссылки документации
(`check-docs --external=warn` на ветках `issue/*`): упавший чужой сайт не
возвращает задачу. `performance.yml` в список не входит:
по расписанию он судит `main` собственным телом из `main`.
**Цена захода зависит от трека** (#696, решение владельца 2026-09-28). Трек
@@ -1230,7 +1297,8 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
- если `dev` не двигался — push с `--force-with-lease` на текущую вершину;
- если двигался — ребейз (конфликт — `S6-in-progress`, как раньше), сравнение
patch-id проверенного и получившегося диффа (различие — `S7-code-review`: вердикт
к другому диффу не применим, §2.10), публикация кандидата в ветку задачи, запуск
к другому диффу не применим, §2.10; метка снимается и ставится заново, чтобы
событие запустило новый заход, #706), публикация кандидата в ветку задачи, запуск
Validate с мутантами на ней (#510) и ожидание зелёного dispatch-прогона **на этом
SHA** — push-прогон мутантов не несёт — и только затем push в `dev` с lease на ту
вершину, поверх которой кандидат собран. На `show`/`ship` мутантов нет, и
@@ -1238,7 +1306,11 @@ npm ci, Python и Chromium, оставаясь исполненной job: до
push-прогон, а dispatch шлёт, только если его нет за три минуты (#696). Отклонённый lease — `dev` двинулся снова
— новая попытка; после третьей — `S6-in-progress` с комментарием;
- красный Validate на кандидате или прогон, не появившийся за три минуты, —
`S6-in-progress` с ссылкой; `S8-merged` ставится только после push.
`S6-in-progress` с ссылкой; `S8-merged` ставится только после push;
- после push ветка задачи удаляется с lease на влитую вершину (#702): коммит,
прилетевший после слияния, её сохраняет, и комментарий об этом говорит.
Влитая ветка никому не нужна, а агент, ищущий ветку по номеру, иначе может
взять устаревшую.
Проверка кандидата — обычный Validate ветки: лёгкий набор плюс диффозависимые
гейты. Тяжёлые гейты остаются за кандидатом релиза (#479): слияние не превращает
+8
View File
@@ -340,6 +340,9 @@ const out = await page.evaluate(async () => {
await hp.setTool('select');
await selectStair(straight.id);
result.frameReturnsUnderSelect = !!frame();
// #693: the plan editor keeps `move` over the stair body.
result.editorStairBodyCursorIsMove = getComputedStyle(
stairNode(straight.id)?.querySelector('.hp-stair-hit') ?? document.body).cursor === 'move';
await hp.setTool('stairs');
// Saving the properties dialog untouched changes nothing (#676 AC5):
@@ -426,6 +429,10 @@ const out = await page.evaluate(async () => {
result.validLinkIsAccessible = linkedNode?.getAttribute('role') === 'link'
&& linkedNode?.getAttribute('data-target-state') === 'active'
&& getComputedStyle(linkedNode).cursor === 'pointer';
// #693: in View the hit area on top of the outline shows the link's
// pointer, not the editor's `move`.
const hitCursor = (node) => getComputedStyle(node?.querySelector('.hp-stair-hit') ?? document.body).cursor;
result.viewLinkHitCursorIsPointer = hitCursor(linkedNode) === 'pointer';
// Hover on a link announces the target floor; every other target state
// stays silent (#676 AC8). The mouse pointer type enables hover.
@@ -451,6 +458,7 @@ const out = await page.evaluate(async () => {
};
result.missingTargetHoverIsSilent = await hoverTip(await withTarget(null), null)
&& stairNode(linked.id)?.getAttribute('data-target-state') === 'missing';
result.viewStairWithoutTargetHasNoMoveOrPointer = !['move', 'pointer'].includes(hitCursor(stairNode(linked.id)));
result.selfTargetHoverIsSilent = await hoverTip(await withTarget('f1'), null)
&& stairNode(linked.id)?.getAttribute('data-target-state') === 'self';
result.deletedTargetHoverIsSilent = await hoverTip(await withTarget('no-such-space'), null)
+4
View File
@@ -2,6 +2,10 @@
## Unreleased
- In View, the mouse cursor over a stair link is now a pointer instead of the
move cursor; stairs without a valid target keep the plan's ordinary cursor
([#693](https://github.com/Matysh/houseplan-card/issues/693)).
## v1.78.0 — 2026-09-28
- Volumetric 2.5D View is now a public General settings option. Device markers,
+4
View File
@@ -8,6 +8,10 @@
## Не выпущено
- В режиме View над лестницей-ссылкой теперь курсор-указатель вместо курсора
перемещения; у лестниц без действующей цели — обычный курсор плана
([#693](https://github.com/Matysh/houseplan-card/issues/693)).
## v1.78.0 — 2026-09-28
- Объёмный 2.5D-вид стал публичной опцией в общих настройках. Положение
+8 -2
View File
@@ -330,7 +330,10 @@ the `dev` branch. Access to the owner's instances is not documented here.
- The card module URL contains `?v=<VERSION from const.py>`. Browsers keep the ES module in
memory cache: after deploying new JS **bump VERSION in const.py and restart HA**,
otherwise a plain F5 will keep the old version.
otherwise a plain F5 will keep the old version. This is a deployment step — a
release candidate or your own local stand. An ordinary task commit bumps neither
`VERSION` nor the committed bundle: both change only in a commit with a
`Release:` trailer (#657, `PROCESS.md` §1).
- After a page reload the HA frontend (with kiosk-mode) sometimes leaves the view empty
("InvalidStateError: Transition was aborted", hui-view is not created for 1–2 min).
Cured by repeating the SPA navigation: pushState + a location-changed event, or just waiting.
@@ -525,7 +528,10 @@ above); the dev stand takes the head of `dev` from the `dev-build` branch
Prepare the candidate as usual: synchronize every version field, add dated RU
and EN changelog sections, update the production bundle snapshots with
`npm run bundle:release` (since #657 the only commit that may change them; it
carries the `Release:` trailer) and write the
carries the `Release:` trailer), then lower the ratchets to the candidate's
facts with `node scripts/ratchets.mjs tighten` (#699: it rewrites the core line
caps, the gzip graph ceilings and `scripts/monolith-baseline.json` from the
fresh `dist/`; commit them with the candidate) and write the
short bilingual body in `docs/RELEASE-NOTES.md`. That file is the one current
instance of the canonical `## Основное` / `## Highlights` template; its two
changelog links must be pinned to the new tag.
+4 -3
View File
@@ -3,8 +3,9 @@
Датированные выводы, пережившие свою задачу (#635). Каждый — одной мыслью, со
ссылкой на источник: документ ревью, issue, аудит. Полный след решений — в
`docs/reviews/INDEX.md` и комментариях issue; здесь только то, что меняет
поведение на следующей задаче. Конспекты ролей (#634) и промпт ревьюера
ссылаются сюда.
поведение на следующей задаче. Читается перед задачей в затронутой подсистеме
вместе с её строками в `docs/reviews/INDEX.md` (PROCESS.md, «Индекс документов
ревью»); конспекты ролей сюда не ведут — урок, ставший правилом, живёт в каноне.
| Дата | Урок | Источник |
|---|---|---|
@@ -12,7 +13,7 @@
| 2026-09-06 | Число смоков/тестов не живёт в тексте: считается командой (`npm run inventory`). | #472, PROCESS.md |
| 2026-09-09 | Мутанты по диффу — на кандидате, не на каждом push: 48 из 56 часов job-минут за два дня ушли в отменённые прогоны. | #510 |
| 2026-09-13 | Ненулевой exit гарда ≠ «мутант пойман»: setup-failure, invalid-mutation и survived различаются; засчитывается только assertion-killed. | #558, #568 |
| 2026-09-18 | Ревью на не принятых эталонах — потерянный цикл: `golden:verify` ревьюер гоняет лично, эталоны принимаются до `S7`. | CODE-REVIEW-598-r1 H1 |
| 2026-09-18 | Ревью на не принятых эталонах — потерянный цикл: `golden:verify` ревьюер гоняет лично, эталоны принимаются до `S7`. С #697 — только у задачи с `ci:golden`; сдвиг остальных принимает бот на `dev` перед бетой. | CODE-REVIEW-598-r1 H1, #697 |
| 2026-09-20 | Стенд ≠ Home Assistant: нативный `<dialog>` и `ha-dialog` — разные ветки; свидетель для реального HA — фикстура #505, а не golden стенда. | аудит 22.09 A/H1, #607, #609 |
| 2026-09-20 | Контролируемое поле без `live()` показывает не то число, что в состоянии; клампить на коммите (`change`), не на каждом символе. | аудит 22.09 A/H2, #608 |
| 2026-09-21 | Лог без итоговой строки — обрыв, а не «ok»: отчёт судит по маркеру завершения и исходу шага, не по отсутствию `FAIL`. | #604 |
+4 -1
View File
@@ -53,7 +53,10 @@ activation a no-op.
In View, hovering a stair with a mouse shows the card tooltip "Go to floor
<title>" when — and only when — the stair is a valid link (`active` target
state); a missing, self, deleted or fixed-floor target shows no tooltip.
state); a missing, self, deleted or fixed-floor target shows no tooltip. The
mouse cursor follows the same condition: a pointer over a valid link, the
plan's ordinary cursor over any other stair. The move cursor is the Plan
editor's (#693).
In an ordinary multi-space card, a clean click/tap or keyboard activation on a
valid stair switches to the target tab and restores that floor's remembered
+4 -2
View File
@@ -3,8 +3,10 @@
> The current state for a resuming session: a generated snapshot, the current
> cycle and the standing decisions that explain it. Rules are not here — the
> process is `PROCESS.md`, release mechanics are `docs/DEVELOPMENT.md` › Release,
> and task scope and status live in GitHub Issues and their labels. Update a row
> in the same commit as the change it describes (`PROCESS.md` §2.6).
> and task scope and status live in GitHub Issues and their labels. The snapshot
> below is generated — never edit it by hand; the prose sections after it are
> edited by hand, a row in the same commit as the change it describes
> (`PROCESS.md` §2.6).
## Snapshot
+30 -15
View File
@@ -28,10 +28,12 @@ golden, а то, чего автоматика не видит, собрано
подложки, Glow и декора проверяет пустую страницу; солнце с азимутом, при
котором луч не достигает единственного окна (#89), — та же ошибка в
геометрии.
4. **Тест, охраняющий механизм, сопровождается мутантом** в
`scripts/mutation-registry.mjs`: 2–5 строк патча, воспроизводящего поломку,
против которой тест заведён, и тест обязан на ней краснеть. Чистым функциям
с обычными юнитами мутант не нужен.
4. **Мутант в `scripts/mutation-registry.mjs` обязателен, когда защита живёт в
продуктовом коде и проверяется дорогим гейтом** (смок, бэкенд, golden):
2–5 строк патча, воспроизводящего поломку, против которой тест заведён, и
тест обязан на ней краснеть. Для чистых юнитов достаточно прогона со снятой
защитой, приведённого в документе ревью. Правило одно — PROCESS.md §2.7
(#701); на треке `show` отсутствие мутанта — Low (§10.4).
5. **Тавтологический ассерт — читающий то же свойство, которое код только что
выставил, — не пишется вовсе.** Он может упасть только при удалении строки,
но не при её неработоспособности.
@@ -45,7 +47,8 @@ golden, а то, чего автоматика не видит, собрано
Проверка: `node scripts/mutation-gate.mjs --check` — якоря патчей живы;
полный прогон — workflow `mutation-gate.yml` (шесть чересполосных шардов
`--shard=i/6` — при четырёх шард упёрся в потолок 60 минут на 810 мутантах, #604;
`--shard=i/6` — при четырёх шард упёрся в потолок 60 минут, #604; число мутантов
считает `npm run inventory`, а не этот текст;
один зафиксированный commit/tree для всего прогона; каждый артефакт несёт
identity и исход шага прогона, а отдельный агрегатор fail-closed отвергает
смешанные, неполные или прерванные по таймауту evidence даже при частичном
@@ -250,6 +253,12 @@ c._drag = { id, sx, sy }; // private-ok: #NNN состояние жеста, о
## Локальный набор перед пушем (#343)
Обязательный локальный набор — `npm run gate:small`; его состав живёт в
`scripts/gate-small.mjs` и нигде больше не переписывается (#701). Хук `pre-push`
гоняет его сам для веток задач — таблица «В хуке» ниже. Ручной
`node scripts/pre-push-gate.mjs` из этого раздела — расширенный прогон:
типы, юниты, смоки и мутанты по диффу.
Красный CI — дорогой способ узнать о проблеме: пять минут ожидания, а при
код-ревью ещё и лишний раунд. Прецедент назван в задаче: находка r2-H1 в #329
стоила целого раунда и ловилась локальным `npm test`.
@@ -296,7 +305,10 @@ node scripts/pre-push-gate.mjs --max-smokes=3 --max-mutants=1
Три вида ответа `smoke-select` различаются и здесь: дифф без исполняемого кода —
«смоки не требуются»; прямое совпадение или зарегистрированная связь —
прогоняется; **связь не доказана** — отдельная громкая строка, потому что это не
«проверять нечего»: молчание стоило #234 бета-блокирующего регресса.
«проверять нечего»: молчание стоило #234 бета-блокирующего регресса. Вместе с ней
печатается визуальный минимум (`VISUAL_MINIMUM` в `scripts/smoke-select.mjs`):
восемь смоков режимов, слоёв и отрисовки, которые `--smokes` гоняет вместе с
выборкой (#690). CSS-правка #687 без него не выбрала `smoke_modes`.
### В хуке — по умолчанию для веток задач (#633)
@@ -439,14 +451,17 @@ golden отображаемая версия идёт через seam `displayVe
- [ ] `node --test test/core-file-budget.test.mjs` — потолки на
`src/houseplan-card.ts` и `src/houseplan-editor-runtime.ts`.
Гейт-храповик: наверх не пускает, вниз требует зафиксировать выигрыш. Если он
покраснел, вариантов два, и оба нормальные — вынести из ядра столько же строк,
сколько добавили, либо поднять потолок отдельным решением, объяснив его в
ревью. Молча поднять не выйдет: число живёт в тесте и попадает в дифф.
Гейт-храповик с полосой (#699): потолок — факт последней беты, ветка может
вырасти над ним на 50 строк. Если он покраснел, вариантов два, и оба
нормальные — вынести из ядра столько же строк, сколько добавили сверх полосы,
либо поднять потолок отдельным решением, объяснив его в ревью. Молча поднять не
выйдет: число живёт в тесте и попадает в дифф.
Вторая половина правила (уменьшение ниже потолка на 250 строк тоже краснеет)
нужна затем, что без неё вынос двух тысяч строк ничего не меняет: потолок
остаётся прежним, и ядро дорастает до него обратно «в рамках бюджета».
Вторая половина правила — выигрыш фиксируется — переехала на бету: без неё
вынос двух тысяч строк ничего не меняет, и ядро дорастает до потолка обратно
«в рамках бюджета». Поэтому на кандидате беты `node scripts/ratchets.mjs
tighten` опускает потолки ядер, графов бандла и базу монолита до факта
(PROCESS.md §8, «Храповики»); ветку задачи уменьшение не красит.
## Локальный CI-совместимый toolchain (#557)
@@ -736,8 +751,8 @@ See `demo/performance/README.md` for commands and the budget-review contract.
Остальные пункты с пометкой `[manual]` описывали обычное поведение режимов,
редакторов, диалогов, правил значков и `houseplan-space-card`. Для них нужен
смок, а не ручная проверка: задача, которая трогает такую поверхность без
смока, пишет его в том же коммите (правило `[auto: …]` выше). Пункты
`[manual]` разделов ниже остаются как есть.
смока, пишет его в том же коммите (правило `[auto: …]` выше). Оставшиеся пункты
`[manual]` разделов выше остаются как есть.
## Release regression quickies
+16 -10
View File
@@ -78,6 +78,10 @@
входит в issue. Всё, чего пользователь не наблюдает, автор решает сам и
записывает блоком «принято предположительно, поменять свободно». Смешанный
вопрос делится ([§7.1](../../PROCESS.md#71-цепочка)).
- Дефект растра, резкости или композитинга: до кода нужен свидетель, красный
на старом коде именно по симптому владельца; в AC — подтверждение владельца
в реальном GPU-браузере. Headless-доказательства мало (урок #685 → #689,
[§7.1](../../PROCESS.md#71-цепочка)).
- Вопросы — одним комментарием, пачкой: что неясно · что изменится от ответа ·
вариант по умолчанию. Пока ждём ответа, issue остаётся в `S3-spec` и
получает `blocked` ([§7.1](../../PROCESS.md#71-цепочка)).
@@ -94,12 +98,14 @@
## Реализация (`S6-in-progress`)
- Занятие: `Взял: <роль> · сессия <id> · ветка issue/NN-slug`; WIP — одна
задача в разработке на исполнителя
задача в разработке на исполнителя, не больше трёх на цикл релиза и двух в
код-ревью
([§2.6](../../PROCESS.md#26-в-разработке--реализация),
[§7.2](../../PROCESS.md#72-шаблоны-комментариев)).
- Ветка `issue/<NN>-<slug>`; каждый коммит несёт трейлеры `Issue: #<NN>` и
`User-Visible: yes|no`. `User-Visible: yes` требует правок в обоих changelog
в том же коммите. После `cherry-pick -x` трейлеры остаются последним блоком
- Ветка `issue/<NN>-<slug>`; каждый коммит с файлами классов A, B или D несёт
трейлеры `Issue: #<NN>` и `User-Visible: yes|no`, коммит только из
документации — без трейлеров. `User-Visible: yes` требует правок в обоих
changelog в том же коммите. После `cherry-pick -x` трейлеры остаются последним блоком
([§2.6](../../PROCESS.md#26-в-разработке--реализация),
[§3 п.10](../../PROCESS.md#3-правила)).
- Автотесты — часть реализации: каждый AC с пометкой `unit` / `backend` /
@@ -137,13 +143,13 @@
## Гейты перед хендоффом
- Минимальный набор по изменённым поверхностям: `npx tsc --noEmit`,
`npm test`, `npm run build` + `bundle-policy --verify`, `smoke-select` и
целевые смоки, `no-new-any`; по диффу — `model-invariants`,
- Обязательная часть — `npm run gate:small`: его состав живёт в
`scripts/gate-small.mjs` и нигде не переписывается. По диффу и AC сверх
него — целевые смоки из вывода `smoke-select` (связь не доказана — его
визуальный минимум, `--smokes` гоняет его сам, #690), `model-invariants`,
`pytest tests_backend`, junction parity; `golden:verify` — только с меткой
`ci:golden`. Команды —
в каноне ([§8](../../PROCESS.md#8-гейты)); `npm run gate:small` собирает
обязательную часть (`docs/TESTING.md`, «Локальный набор перед пушем»).
`ci:golden` ([§8](../../PROCESS.md#8-гейты); `docs/TESTING.md`, «Локальный
набор перед пушем»).
- Бандл в коммит задачи не идёт: сборка переписывает отслеживаемый `dist/`,
перед коммитом — `npm run bundle:clean`; хук `commit-msg` отклоняет пути
бандла без трейлера `Release:` (#657, [§1](../../PROCESS.md#1-основное-правило)).
+4 -3
View File
@@ -83,9 +83,10 @@
([§8](../../PROCESS.md#8-гейты)).
- Условие честности сужения: ревьюер обязан перечислить, какие гейты прогнал,
какие нет и почему ([§8](../../PROCESS.md#8-гейты)).
- Зелёный `pytest tests_backend` без Home Assistant скипает `test_ha_*.py` и
ничего не доказывает — это «чего не проверял» (`docs/TESTING.md`;
[§8](../../PROCESS.md#8-гейты)).
- Зелёный `pytest tests_backend` без Home Assistant `test_ha_*.py` не
собирает вовсе — их нет ни в `passed`, ни в `skipped` (строка `HA harness NOT
collected`), и такой прогон про HA ничего не доказывает — это «чего не
проверял» (`docs/TESTING.md`; [§8](../../PROCESS.md#8-гейты)).
## Повторный раунд
+134
View File
@@ -0,0 +1,134 @@
# CODE-REVIEW-690-r1
**Issue:** #690 · **Этап:** code (`S7-code-review`) · **Трек:** show (инфраструктурный) ·
**Заход:** r1 · **Блокирующих циклов:** 0 из 2 · **Материал:** ветка
`issue/690-visual-minimum`, ровно `52a56430abbf98f66233b9637a5f7c78cc8d0600`
(рабочая копия на нём; `git rev-parse HEAD` сверен).
## Скоуп
Три AC из ТЗ (тело issue, п. 1′/4/попутно, решения владельца 2026-09-28):
- **AC1.** `smoke-select` при исполняемом диффе без доказанной связи печатает и
отдаёт в JSON фиксированный «визуальный минимум» (5–8 смоков режимов, слоёв,
отрисовки, среди них `smoke_modes`); `gate:small -- --smokes` гоняет его
вместе с выборкой; при доказанной связи или без исполняемого диффа минимума
нет.
- **AC2.** Правило для дефектов растра/резкости/композитинга — свидетель,
красный на старом коде по симптому владельца, подтверждение в реальном
GPU-браузере — в PROCESS §7.1 и `docs/process/AUTHOR.md`.
- **AC3.** `task-packet.mjs` описывает прежнюю метку `trivial` как продуктовый
поток, читаемый как `track:show` (§5.1), а не как «короткий трек без ТЗ».
Диффа класса A нет (только `PROCESS.md`, `docs/TESTING.md`,
`docs/process/AUTHOR.md`, `scripts/gate-small.mjs`,
`scripts/mutation-registry.mjs`, `scripts/smoke-select.mjs`,
`scripts/task-packet.mjs`, `test/smoke-select.test.mjs`,
`test/task-packet.test.mjs`) — класс B+C, `User-Visible: no` в единственном
коммите корректен (видимого пользователю поведения карточки нет). Трейлер
`Issue: #690` на месте.
## Как проверялось
Дешёвые гейты подтверждены зелёным Validate на этом SHA (run 36528250333,
`typecheck`+`npm test`+`npm run build`+`bundle-policy --verify`) — не
перегонялись повторно.
Дифф по `smoke-select` на самом материале:
```
$ node scripts/smoke-select.mjs --base origin/dev --head HEAD
Исполняемого frontend-диффа нет (src/**/*.ts не тронут).
Browser-smoke этим диффом не выбираются...
```
`noExecutableDiff: true` — ни один из названных в АС1 смоков, ни визуальный
минимум задачей не требуется: сама задача не трогает `src/**`. Браузерные
смоки не гонялись — по выводу инструмента они не выбраны, а тело issue не
называет смок отдельно для этого прогона.
По каждому AC — что проверено и как:
| AC | Чем доказан | Чем краснеет | Прогон |
|---|---|---|---|
| AC1 | `test/smoke-select.test.mjs` (`#690 п.1′: …`, 2 новых теста) + 3 именованных мутанта | `visual-minimum-silent-again` (обнуляет `visualMinimum`), `visual-minimum-on-proven-link` (минимум течёт по одному факту исполняемости, не по недоказанности), `gate-small-skips-visual-minimum` (убирает минимум из `smokesToRun`) | Прогнал сам: baseline зелёный (`node --test --test-name-pattern="#690" test/smoke-select.test.mjs` — 2/2 pass); применил патч каждого из трёх мутантов вручную — все три ломают ровно ожидаемый тест (проверено построчно, дифф отменён после каждой пробы). `node scripts/mutation-registry-check.mjs` — 0 (реестр непротиворечив, `find`-строки совпадают с текущим кодом) |
| AC2 | Текст в PROCESS §7.1 (строки 693–696) и `docs/process/AUTHOR.md` (раздел ТЗ) | защитный AC текстового/процессного рода — «чем краснеет» не применимо к формулировке правила самой по себе; доказательство — точное соответствие формулировке владельца из комментария 2026-09-28 | Проверено чтением, не исполнением: сверил текст правила в обоих файлах с формулировкой владельца дословно — совпадает; место в §7.1 логически стоит рядом с «Размытое место не додумывается» (тот же смысловой блок про доказательства до кода), не конфликтует с соседним текстом |
| AC3 | `test/task-packet.test.mjs` (обновлённое `productFlowEvidence` ожидание), `scripts/mutation-registry.mjs` (`find`/`replace` синхронизированы с новой строкой) | тот же мутант `task-packet-trivial-is-product-flow`, `because` тоже обновлён, чтобы не противоречить #695 | `node --test test/task-packet.test.mjs` — 16/16 pass; сверил формулировку с уже принятым #695 (`scripts/process-track.mjs:39`, `PROCESS.md:935`) — согласовано, не изобретает нового понятия |
Дополнительно проверено:
- `node --test test/smoke-select.test.mjs` — 9/9 pass (полный файл, не только `#690`-подмножество).
- `node --test test/gate-small.test.mjs` — 5/5 pass.
- `git diff --stat` — бандл (`dist/**`) не тронут, `Release:`-трейлер не нужен.
- Единственное число, дублирующееся в диффе, — «восемь» смоков визуального
минимума: один источник (`VISUAL_MINIMUM.length` в `scripts/smoke-select.mjs`),
PROCESS.md и `docs/TESTING.md` его лишь цитируют текстом, не хардкодят
отдельно; `test/smoke-select.test.mjs` проверяет диапазон 5–8, а не точное
число, так что смещение массива само по себе не расходится с документами
сильнее допустимого. Все 8 файлов `demo/smoke_*.mjs` из `VISUAL_MINIMUM`
существуют (проверено `existsSync`, тест это же утверждает).
## Находки
Нет. Изменение узкое, три AC разобраны по коду и тестам, мутанты
воспроизведены вручную и действительно красные на инвертированном
поведении, документный AC2 текстуально совпадает с решением владельца, AC3
согласован с уже принятым #695 и не вводит расхождений.
## Что проверено и корректно
- `VISUAL_MINIMUM` — 8 существующих смоков, включает `smoke_modes.mjs`
(именно его не хватило #687 по тексту issue); список заморожен
(`Object.freeze`).
- `selectSmokes()`: `visualMinimum` заполняется только при `unproven`, при
доказанной связи (прямой или зарегистрированной) и при `noExecutableDiff`
остаётся пустым — подтверждено фикстурами `unproven.diff`,
`234-chain-thickness.diff`, `docs-only.diff`.
- `smokesToRun()` в `gate-small.mjs` включает `visualMinimum` в список для
прогона наравне с `direct`/`registered`, с дедупликацией через `Set`.
- CLI (`report()`) печатает визуальный минимум отдельной громкой строкой
внутри ветки `НЕОПРЕДЕЛЁННОСТЬ», не подменяя её.
- `gate-small.mjs` итоговый лог называет причину («связь не доказана — …
визуальный минимум (#690)») отдельно от обычного случая.
- Мутант `task-packet-trivial-is-product-flow` обновлён синхронно с
изменением текста в `scripts/task-packet.mjs` — `find`-строка совпадает,
`mutation-registry-check.mjs` подтверждает.
- Единственный коммит, трейлеры `Issue:`/`User-Visible:` корректны,
ветка от актуального `dev` (`6e073134`), файлов класса A нет.
## Чего не проверял
- Полный `npm run gate:small`, `npm test`, `npm run build`,
`golden:verify`, `pytest tests_backend`, `npm run invariants` — не
прогонял: Validate зелёный на этом SHA закрывает дешёвые гейты
(typecheck/test/build/bundle-verify), диф не трогает Python и геометрию,
`ci:golden` не выставлена.
- Браузерные смоки (включая сам `VISUAL_MINIMUM`) — не гонял: диф не
трогает `src/**`, `smoke-select.mjs --base origin/dev --head HEAD` на
материале прямо отвечает `noExecutableDiff`, обязательного смока в АС нет.
- Мутанты по диффу как отдельный обязательный гейт ревью — не запрашивались
(трек `show`, #690/REVIEWER.md «Трек show»); три названных в АС1 мутанта
всё же проверены вручную вручную, потому что AC1 защитный и без третьей
колонки таблицы «чем краснеет» находка была бы Medium.
- Performance-профили — не названы в AC, не гонял.
- Поведение конвейера `_process.yml`/меток `trivial` на реальных открытых
issue — не проверял вживую; ограничился текстовым сравнением с уже
принятым #695.
## Вердикт
Зелёный. AC1–AC3 выполнены и доказаны каждый своим способом (тест+мутант,
чтение текста, тест); находок нет.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/690-visual-minimum`, коммит `52a56430abbf` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `3e5463d83784c360485d89dd297cbd4cbb816e5c`
```
git log --all --format='%H %T' | grep 3e5463d83784
```
- Тело issue: `4796e50b141f022b6ef98e61809bd29d3dd30e0dcf87f368da80b697565b156b`
- Вердикт конвейера: `green` · High 0
+222
View File
@@ -0,0 +1,222 @@
# CODE-REVIEW #698 r1
Материал: `origin/dev..HEAD`, ровно один коммит `ea2c000af5e4bd47140c2c255fca16ad2a136f6c`
поверх `dev@9e4bfb43` (рабочая копия уже на нём). Диапазон: `.gitattributes`,
`PROCESS.md`, `scripts/merge-candidate.mjs`, `scripts/mutation-registry.mjs`,
`scripts/rebase-generated.mjs`, `test/rebase-generated.test.mjs`. Файлов класса
A нет — инфраструктурный трек (§1), вход сразу на `S7-code-review`, метка
`track:show` на issue.
## Скоуп
Issue #698 называет три источника из 14/48 возвратов #600–#691 по общим
файлам, где правки двух задач не противоречат друг другу: (1) записи
`## Unreleased` в обоих CHANGELOG, (2) `docs/images/screenshots.json`, (3)
`scripts/bundle-budget.mjs` / `scripts/monolith-baseline.json`. Формального ТЗ
нет (инфраструктура, PROCESS.md §1) — проверял по тексту раздела «Предложение»
issue и по факту автоматизации, а не по чужому изложению.
Реализовано:
- `.gitattributes`: `merge=union` для `docs/CHANGELOG.md` и
`docs/CHANGELOG.ru.md` — строки `## Unreleased` объединяются на ребейзе,
слиянии и `git merge-tree` без остановки.
- `scripts/rebase-generated.mjs`: `UPSTREAM_WINS = ['scripts/monolith-baseline.json']`
— конфликт именно на этом пути решается в пользу `dev` (`checkout --ours`),
без abort; любой другой путь по-прежнему аборт с полным списком.
- `scripts/merge-candidate.mjs`: `PATCH_ID_EXCLUDES` расширяет прежнее
исключение `docs/reviews` на оба CHANGELOG и `monolith-baseline.json` — эти
файлы больше не входят в patch-id кандидата, соседняя строка не отправляет
зелёную задачу обратно на ревью.
- `docs/images/screenshots.json` сознательно не тронут: автор в заявочном
комментарии ссылается на #697 («после #697 ветки задач его не коммитят»).
На момент этого ревью #697 в статусе `S1-new` (не начат, не триажирован) —
конфликты на этом файле продолжат случаться до его реализации. Автор это
прямо раскрыл, не спрятал; тема не входит в делаемый диапазон #698 (см.
«Что проверено»).
- `scripts/bundle-budget.mjs` не включён ни в `UPSTREAM_WINS`, ни в
`PATCH_ID_EXCLUDES`, хотя исходная «Проблема» issue называет его рядом с
`monolith-baseline.json`. Прочитал файл: он вперемешку хранит числовые
потолки и содержательные словари (`GRAPH_LABELS`,
`NAMESPACE_ENGLISH_CONSUMERS`, `SUPPORT_LAZY_MARKERS`), большинство числовых
потолков уже имеют полосу (`INITIAL_VIEW_CEILING_BAND`,
`LAZY_GRAPH_CEILING_BAND`) — это не тот же тип конфликта, что чистый
производный JSON `monolith-baseline.json`, и слепое разрешение «взять dev»
рисковало бы стереть чужой словарь. Решение не включать файл в это ревью не
оспариваю.
## Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Validate на материале | CI run на `ea2c000a` (ссылка в задаче ревью) | зелёный; дешёвые гейты (`tsc`, `npm test`, `build`+`bundle-policy --verify`) не перегонял — приняты по этой ссылке (#343) |
| Целевой юнит-набор | `node --test test/rebase-generated.test.mjs` | 15/15 pass |
| Целевой юнит-набор | `node --test test/merge-candidate.test.mjs` | 19/19 pass |
| Целевой юнит-набор | `node --test test/rebase-on-dev.test.mjs` | 6/6 pass |
| Согласованность конспекта | `node --test test/process-digests.test.mjs` | 5/5 pass |
| `process-gate.mjs` (офлайн) | `node scripts/process-gate.mjs --issues` | «гейт пройден, предупреждений 1» — предупреждение информационное: инфраструктурный диапазон, статусная метка не обязательна до `S7-code-review` |
| Реестр мутантов, дешёвая половина | `node --test --test-name-pattern="every mutant patch anchors exactly once\|every guard command points" test/mutation-gate.test.mjs` | 2/2 — якоря трёх новых мутантов не отстали от кода |
| Мутант `changelog-union-driver-dropped` | вручную убрал строку `docs/CHANGELOG.md merge=union` из `.gitattributes`, прогнал `node --test --test-name-pattern="#698: записи ченджлога" test/rebase-generated.test.mjs`, восстановил файл | **краснеет** (`AssertionError`, `false !== true`) |
| Мутант `monolith-baseline-conflict-is-manual-again` | вручную заменил `UPSTREAM_WINS` на `Object.freeze([])` в `scripts/rebase-generated.mjs`, прогнал `--test-name-pattern="#698: конфликт в базе"`, восстановил файл | **краснеет** (`ok:false, reason:'manual'` вместо ожидаемого авторазрешения) |
| Мутант `merge-patch-id-sees-changelog` | вручную вернул `PATCH_ID_EXCLUDES` к списку без обоих CHANGELOG в `scripts/merge-candidate.mjs`, прогнал `--test-name-pattern="#698: patch-id"`, восстановил файл | **краснеет** (`AssertionError` на `docs/CHANGELOG.md`) |
| Чтение живого гейта монолита | `scripts/monolith-metrics.mjs: compareWithBaseline` | `band = name === 'bundleBytes' ? bundleBand : 0` — для 5 из 6 метрик допуск нулевой сегодня (см. находку Medium) |
| Провенанс коммита | `git show -s --format=full ea2c000a` | `Issue: #698`, `User-Visible: no` — корректно, ни одно пользовательское поведение продукта не задето |
| Восстановление рабочей копии | `git status --porcelain` после каждой ручной мутации | пусто — дерево вернулось к исходному состоянию перед следующим шагом |
Не прогонял (и почему): `npx tsc --noEmit`, полный `npm test`, `npm run build`
+ сверка трёх копий бандла, `node scripts/check-docs.mjs`, `mutation-gate --check`
целиком (с пересборкой бандла на мутанта) — приняты по зелёному Validate на
этом SHA (#343); диапазон не трогает `src/**`, `check-docs.mjs` не требуется.
Golden/скриншоты, браузерные смоки, `pytest tests_backend`, инварианты модели,
performance-профили — не применимо: диапазон не трогает визуал, Python или
геометрию плана.
## Что проверено и корректно
- **`--ours`/`--theirs` во время rebase не перепутаны.** Комментарий в коде
(«На ребейзе `--ours` — сторона, на которую ребейзят, то есть dev») верен
git-семантике (во время `git rebase` HEAD — это цель, `--theirs` —
переигрываемый коммит); тест «конфликт в базе метрик монолита решается в
пользу dev» подтверждает это исполнением реального git: `hostRefs` после
разрешения равен значению из коммита `neighbour` (dev), а не `task`.
- **`planStop`/`rebaseRegenerating` не путают `UPSTREAM_WINS` с `extra`/`index`.**
И `manual`, и возвращаемый `extra` корректно исключают оба новых пути;
`plan.upstream` обрабатывается отдельной веткой (`checkout --ours` + `add`)
до `plan.index`. Тест «база метрик вместе с конфликтом в коде» подтверждает,
что при добавлении обычного (не покрытого) конфликтующего пути срабатывает
прежний полный abort со списком `['a.mjs', 'scripts/monolith-baseline.json']`,
а не частичное разрешение.
- **Оба вызывающих получают правило бесплатно.** `UPSTREAM_WINS` зашита в
`scripts/rebase-generated.mjs`, а не передаётся через `opts.extra` —
и авторский `rebase-on-dev.mjs`, и конвейерный
`merge-candidate.mjs::rebaseOnto` используют общий `rebaseRegenerating` без
дублирования логики.
- **Все три новых мутационных гвардиана реально ловят регресс** — проверено
ручным воспроизведением (патч → красный тест → откат), а не по имени `ok` в
реестре мутантов.
- **Safety net не теряется при неверном бейзлайне.** После авторазрешения в
пользу dev реальное расхождение вычисленных метрик всё равно ловит Validate
самого кандидата: `merge-candidate.mjs` диспатчит `validate.yml` на
смерженный SHA и ждёт результат, красный возвращает задачу в
`S6-in-progress` — молча неверный бейзлайн в `dev` не проходит.
- **Патч-id корректно исключает то, что ребейз сливает сам** — соседняя
правка нейтрального файла не отправляет зелёную задачу обратно на ревью;
подтверждено мутационным тестом.
- **Трейлеры и класс изменений корректны**: `Issue: #698`, `User-Visible: no`,
ни одного файла класса A; `PROCESS.md` документирует изменение тем же
коммитом.
- **`scripts/bundle-budget.mjs` обоснованно вне скоупа этой правки** (см.
«Скоуп») — не переоткрываю как находку.
## Находки
### Medium (в скоупе — блокирует зелёный вердикт)
**PROCESS.md утверждает существование «теста полосы на Validate» для
монолитного бейзлайна, которого сегодня нет.**
Новый абзац этого же коммита (`PROCESS.md`, рядом с #643): «...а на конфликте
в `scripts/monolith-baseline.json` ребейз берёт сторону `dev` — число для
объединённого дерева судит тест полосы на Validate (#699)». Формулировка в
настоящем времени читается как факт о текущей автоматизации.
По факту: `scripts/monolith-metrics.mjs:compareWithBaseline` сегодня считает
`band = name === 'bundleBytes' ? bundleBand : 0` — для пяти из шести чисел
(`delegates`, `portMembers`, `hostRefs`, `portPrivates`, `harnessPrivates`)
допуск нулевой, точное совпадение, снижение тоже FAIL. Это буквально проблема,
которую описывает сам #699 («Двусторонние храповики сейчас с нулевым или
минимальным запасом» / «5 чисел монолита... точное совпадение»), а #699
находится в статусе `S1-new` — не триажирован, тем более не смёржен. «Тест
полосы» реально существует только для одной метрики из шести (`bundleBytes`,
`BUNDLE_BYTES_BAND`) — и этот механизм существовал до #698/#699, не является
их результатом.
Канон сам формулирует правило для себя (шапка PROCESS.md): «При расхождении
этого документа с... `scripts/*` побеждает фактическая автоматизация.
Расхождение при этом не игнорируется, а заводится issue с меткой `process`».
Данный абзац — ровно такое расхождение, внесённое тем же коммитом, который
должен был его избежать: автор или ревьюер, читающий канон после мержа #698,
но до принятия #699, вправе решить, что небольшой дрейф `hostRefs`/`delegates`
после авторазрешения конфликта в пользу dev будет прощён «полосой» — а на деле
Validate уронит кандидата точным несовпадением, и время уйдёт на выяснение,
почему написанное в каноне не совпадает с поведением автоматизации.
Не High: реальное расхождение по-прежнему красит Validate кандидата (см. «Что
проверено» — safety net не теряется), неверный бейзлайн не проходит в `dev`
молча. Но это Medium и в скоупе задачи — формулировку добавила именно она,
чинится точечной правкой в этом же issue, например: «текущий Validate
сравнивает число с `dev` точно (нулевой допуск для всех метрик, кроме
`bundleBytes`) — станет полосой после #699; до этого несовпадение красит
Validate кандидата, а не проходит тихо».
### Low (сняты ревьюером, без правки)
- `scripts/rebase-on-dev.mjs`: предиктивный `--dry-run`-лог
(`splitConflicts`/`predicted.manual`) не знает ни про `merge=union` в
`.gitattributes`, ни про `UPSTREAM_WINS` — при параллельной правке
`docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md` или `scripts/monolith-baseline.json`
он печатает «менялись с обеих сторон... возможен ручной конфликт», хотя
фактический ребейз (несколько строк ниже, через `rebaseRegenerating`)
разрешает его автоматически. Чисто предсказательный текст для человека,
сам исход ребейза не меняет и уже проверен тестами выше. Снимаю: cosmetic,
не мешает ни одному AC этой задачи; дешевле поправить при следующей правке
файла, отдельный issue для строки лога избыточен.
- Риск «`merge=union` склеит содержательный конфликт двух правок одной старой
строки ченджлога в два дубля» — раскрыт самим автором в хендофф-комментарии
как принятый компромисс для append-only лога (дубль увидит релиз-менеджер
при сборке нот беты). Согласен с оценкой, не переоткрываю.
## Чего не проверял
- Живой прогон `merge-candidate.mjs::rebaseOnto` / `_process.yml` на реальном
GitHub Actions с настоящим конфликтом между двумя параллельными PR — не
воспроизводил. Логика проверена реальным git (временные репозитории в
тестах, не мок) и точечными ручными мутациями — это не то же самое, что
живой запуск конвейера на паре реальных веток. **Записываю явно: для факта
исполнения на реальном GitHub Actions — проверено чтением и контролируемым
тестом на настоящем git, не живым запуском workflow.**
- Полный `npm test` (весь набор) не перегонял целиком — принят по зелёному
Validate на этом SHA (#343); точечно перегонял только файлы из дельты
(`rebase-generated`, `merge-candidate`, `rebase-on-dev`, `process-digests`)
плюс дешёвую половину `mutation-gate.test.mjs`.
- `npx tsc --noEmit`, `npm run build` + сверка трёх копий бандла, полный
`mutation-gate --check` (с пересборкой на мутанта) — не перегонял, приняты
по Validate; диапазон не трогает `src/**`.
- golden/скриншоты, браузерные смоки, `pytest tests_backend`, инварианты
модели, performance — не применимо: диапазон не трогает визуал, Python или
геометрию плана.
- Долгосрочная устойчивость find/replace-якорей трёх новых мутантов к
дальнейшим ребейзам `dev` — проверена только на текущем материале.
- Существо #697 и #699 — вне скоупа этой задачи, не проверял по существу,
только прочитал их текущий статус (`S1-new` у обоих) для находки Medium
выше.
## Вердикт
Единственная блокирующая находка — Medium, в скоупе: формулировка PROCESS.md,
добавленная этим же коммитом, описывает как факт «тест полосы на Validate»
для монолитного бейзлайна, которого сегодня нет (нулевой допуск для пяти из
шести чисел, полоса существует только для `bundleBytes` и не как результат
этой задачи). Сам механизм разрешения конфликтов — `.gitattributes` union,
`UPSTREAM_WINS`, `PATCH_ID_EXCLUDES` — реализован корректно, проверен
исполнением настоящего git и ручными мутациями по всем трём защищаемым
линиям, и не создаёт риска молчаливого слияния неверного числа (реальное
расхождение по-прежнему красит Validate кандидата). Без High это жёлтый
вердикт: формулировка канона должна отражать факт автоматизации, а не
опережать ещё не принятую задачу (#699).
Вердикт: жёлтый · заход r1 · блокирующих циклов 1/2 · High: 0 · Medium: 1 → в задаче
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/698-rebase-auto-resolve`, коммит `ea2c000af5e4` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `cb640cf165f861e9b1f8827a5dc40df3c5435e77`
```
git log --all --format='%H %T' | grep cb640cf165f8
```
- Тело issue: `e17d18976553c2da4042b649a2073c581aa5108f58a57cf7c2902b6d639859c0`
- Вердикт конвейера: `yellow` · High 0
+144
View File
@@ -0,0 +1,144 @@
# CODE-REVIEW #698 r2
Материал: `origin/dev..HEAD`, два коммита `ea2c000a` (r1, уже разобран) и
`97d819ad` (новый, только он в скоупе этого раунда) поверх `dev@9e4bfb43`
(рабочая копия уже на `97d819ad`). Дельта раунда — `git diff ea2c000a..97d819ad`:
`PROCESS.md` (10 строк), `scripts/rebase-generated.mjs` (комментарий,
5 строк), плюс публикация `docs/reviews/CODE-REVIEW-698-r1.md` (артефакт
конвейера, не правка автора). Файлов класса A по-прежнему нет —
инфраструктурный трек (§1), `track:show`.
Раунд не первый (PROCESS.md §2.10, #214): разбор — по дельте r1→r2, а не
заново. Дельта локальна и мала (переформулировка одного абзаца канона и
парного комментария в коде, без изменения логики) — полный повторный разбор
механизма (`.gitattributes` union, `UPSTREAM_WINS`, `PATCH_ID_EXCLUDES`) не
требуется, он не менялся байт в байт (см. «Унаследовано из r1»).
## Скоуп
r1 закрыл ровно одну блокирующую находку — Medium: абзац `PROCESS.md`,
добавленный коммитом #698, утверждал в настоящем времени существование
«теста полосы на Validate» для всех шести чисел монолита, которого на
момент r1 не было (полоса — только у `bundleBytes`, остальные пять чисел
судятся точно; полоса для всех шести — отдельная нетриаженная задача #699).
Коммит `97d819ad` переписывает этот абзац и парный комментарий к
`UPSTREAM_WINS`, называя текущий допуск так, как он реализован сегодня, а
не так, как он будет выглядеть после гипотетического #699. Код не менялся
(автор говорит об этом прямо в хендоффе, и `git diff ea2c000a..97d819ad --
scripts/` подтверждает: правка только в блоке `/** ... */` над
`UPSTREAM_WINS`, объявление и вся исполняемая логика — байт в байт те же).
## Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Validate на материале (`97d819ad`) | CI run, ссылка в задаче ревью | success — дешёвые гейты (`tsc`, полный `npm test`, `build`+сверка бандла) не перегонял, приняты по этой ссылке (#343) |
| Соответствие формулировки коду: гейт | `grep -n "lint:unused" package.json` | `"lint:unused": "node scripts/unused-locals-gate.mjs"` — имя гейта в новом абзаце названо верно |
| Соответствие формулировки коду: допуск | `scripts/monolith-metrics.mjs:262-274` (`BUNDLE_BYTES_BAND = 2_000`, `compareWithBaseline`: `band = name === 'bundleBytes' ? bundleBand : 0`) | подтверждает буквально: пять чисел — точное совпадение, `bundleBytes` — полоса 2000 Б, ровно как теперь написано в `PROCESS.md` |
| Соответствие формулировки коду: где стоит гейт | `grep -n "lint:unused" .github/workflows/validate.yml` → строка 809 | гейт действительно часть Validate кандидата, а не только локальный скрипт |
| Целевой юнит-набор (дельта r1, не менялась в r2, но перепроверил на новом SHA) | `node --test test/rebase-generated.test.mjs` | 15/15 pass |
| Согласованность конспектов ролей | `node --test test/process-digests.test.mjs` | 5/5 pass — переписанный абзац не входит в дословные «ключевые правила» `AUTHOR.md`/`REVIEWER.md`, там о #698/#699 ничего не сказано (`grep` по обоим файлам не нашёл упоминаний «тест полосы»/`monolith-baseline`/#699) — расхождения конспекта с новым каноном нет |
| Реестр мутантов, дешёвая половина | `node --test --test-name-pattern="every mutant patch anchors exactly once\|every guard command points" test/mutation-gate.test.mjs` | 2/2 — три мутанта из r1 по-прежнему анкерятся, комментарий не входит в их `find`/`replace` |
| Провенанс коммита r2 | `git show -s --format=full 97d819ad` | `Issue: #698`, `User-Visible: no` — корректно, правка только канона и комментария, продуктового поведения нет |
| `process-gate.mjs` на дельте r2 | `node scripts/process-gate.mjs --range 97d819ad~1..97d819ad` | «гейт пройден, предупреждений 0» |
Не прогонял (и почему): `npx tsc --noEmit`, полный `npm test`, `npm run build`
+ сверка трёх копий бандла, `node scripts/check-docs.mjs`, `mutation-gate
--check` целиком — приняты по зелёному Validate на `97d819ad` (#343);
дельта r2 не трогает `src/**` и не меняет исполняемый код. Golden/скриншоты,
браузерные смоки, `pytest tests_backend`, инварианты модели,
performance-профили — не применимо, как и в r1: диапазон не трогает визуал,
Python или геометрию плана.
## Что проверено и корректно
- **Медиан-находка r1 закрыта по существу, а не косметически.** Новая
формулировка называет реальный гейт (`npm run lint:unused` →
`scripts/unused-locals-gate.mjs` → `compareWithBaseline`), реальный
сегодняшний допуск (пять чисел — точно, `bundleBytes` — полоса 2000 Б) и
корректно относит полосу для всех шести чисел к будущему #699, не выдавая
её за факт. Проверено не по тексту хендоффа, а прямым чтением
`scripts/monolith-metrics.mjs` и `.github/workflows/validate.yml` — цитаты
выше воспроизводимы построчно.
- **Прежний вывод об отсутствии тихого прохождения неверного бейзлайна не
меняется.** Новый текст сам это формулирует явно: «сторона `dev` в базе
краснеет, если задача сама меняла эти числа — это прежний возврат
автору, только после Validate, а не до ревью» — совпадает с тем, что уже
было проверено исполнением в r1 (safety net через Validate кандидата).
- **Код механизма не тронут.** `git diff ea2c000a..97d819ad --
scripts/rebase-generated.mjs` — правка ограничена телом doc-комментария
над `UPSTREAM_WINS`; объявление константы, `planStop`,
`rebaseRegenerating` — те же байты, что проверялись мутациями в r1.
- **Трейлеры и класс изменений корректны**: `Issue: #698`, `User-Visible: no`.
## Закрытие раунда r1
| Находка (r1) | Чем закрыта | Где это видно |
|---|---|---|
| Medium: `PROCESS.md` утверждал как факт «тест полосы на Validate» для всех шести чисел монолита, которого не существует (полоса только у `bundleBytes`) | Абзац переписан в настоящем времени под фактическую автоматизацию: назван гейт (`lint:unused`), сегодняшний нулевой допуск для пяти чисел и полоса 2000 Б для `bundleBytes`; полоса всех шести явно отнесена к будущему #699 | `PROCESS.md` (диапазон строк 392-401, коммит `97d819ad`); тот же текст продублирован в комментарии к `UPSTREAM_WINS` в `scripts/rebase-generated.mjs`; подтверждено чтением `scripts/monolith-metrics.mjs:262-274` и `.github/workflows/validate.yml:809` — формулировка и код совпадают дословно по числам и названию гейта |
## Унаследовано из r1 (без повторной проверки)
- Механизм разрешения конфликтов — `.gitattributes` (`merge=union` на обоих
CHANGELOG), `UPSTREAM_WINS` в `scripts/rebase-generated.mjs`,
`PATCH_ID_EXCLUDES` в `scripts/merge-candidate.mjs`, три новых мутационных
гвардианта — реализован корректно и проверен исполнением реального git и
ручными мутациями. Документ: `docs/reviews/CODE-REVIEW-698-r1.md`, SHA
материала `ea2c000af5e4bd47140c2c255fca16ad2a136f6c`. Не перепроверял
заново мутациями в r2: код этих файлов в дельте r1→r2 не менялся (см.
«Скоуп» и «Как проверялось» выше — единственная правка `scripts/` этого
раунда — комментарий).
- Обоснование, почему `docs/images/screenshots.json` и
`scripts/bundle-budget.mjs` вне скоупа #698, — из r1, не пересматривал:
первое отложено на #697 (раскрыто автором), второе — смешанный файл с
содержательными словарями, не тот же тип конфликта, что чистый
производный JSON.
- Два Low из r1 (предиктивный `--dry-run`-лог `rebase-on-dev.mjs` не знает
про новые правила; риск дублирования строки ченджлога при настоящем
конфликте двух правок) остаются снятыми ревьюером без правки — дельта r2
их не касается.
## Находки
Новых находок в дельте r2 нет. High: 0, Medium: 0.
## Чего не проверял
- Полный `npm test`, `npx tsc --noEmit`, `npm run build` + сверка бандла —
приняты по зелёному Validate на `97d819ad` (#343); дельта r2 не трогает
`src/**`.
- Существо #699 (проектирование полосы для всех шести чисел) — вне скоупа
#698, не проверял по существу; только убедился, что новая формулировка
канона не выдаёт его результат за уже реализованный.
- Живой прогон конвейера на реальном GitHub Actions с настоящим конфликтом
между двумя параллельными PR — как и в r1, не воспроизводил; логика
проверена в r1 реальным git во временных репозиториях, дельта r2 её не
меняет.
## Вердикт
Единственная блокирующая находка r1 (Medium: формулировка канона опережала
не принятую задачу #699) закрыта точечной правкой того же абзаца — новый
текст сверен построчно с фактическим кодом гейта и подтверждён
исполняемыми числами (`compareWithBaseline`, `BUNDLE_BYTES_BAND`,
`validate.yml:809`). Код механизма не менялся, ранее проверенные мутациями
гарантии в силе. Новых находок нет.
Вердикт: зелёный · заход r2 · блокирующих циклов 1/2 · High: 0 · Medium: 0
---
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/698-rebase-auto-resolve`, коммит `97d819ad6757` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `0e024d33642f2428aefb2486c6e12395e3e05209`
```
git log --all --format='%H %T' | grep 0e024d33642f
```
- Тело issue: `e17d18976553c2da4042b649a2073c581aa5108f58a57cf7c2902b6d639859c0`
- Вердикт конвейера: `green` · High 0
+213
View File
@@ -0,0 +1,213 @@
# CODE-REVIEW-699-r1
Issue: #699 · этап: code · трек: show · заход: r1 · блокирующих циклов до этого раунда: 0/2
Материал раунда: `git log --oneline origin/dev..HEAD` / `git diff origin/dev...HEAD`,
вершина ветки `issue/699-ratchet-bands` = `709c5b8a38f4ac16f7ce18593ef0a40d672742cb`.
Validate на этом SHA — success (run 36486854256). Ребейз на dev не делался
(трек show, #696): dev впереди на 14 коммитов, слияние без конфликта
(`git merge-tree`, подтверждено автором); материал — ветка как есть.
Предыдущего раунда ревью по этой задаче не было: заход `3dd032d7` (первая
попытка) упал на Validate до того, как ревью запускалось («ревью не
запускалось», код никто не читал, цикл не потрачен). Второй заход `709c5b8a`
чинит ровно то, что назвал прогон, и это первый заход, дошедший до ревьюера —
поэтому разбор ниже полный, а не по дельте (§2.10 к этой задаче неприменим).
## Скоуп
Инфраструктурная задача (процесс/гейты), файлов класса A нет. Меняет правило
двусторонних храповиков с нулевым запасом на полосу над потолком последней
беты: строки двух ядер (`test/core-file-budget.test.mjs`, полоса 50), gzip
initial View и трёх ленивых графов (`scripts/bundle-budget.mjs`, полоса
2000 Б), шесть чисел связности монолита (`scripts/monolith-metrics.mjs`,
`scripts/unused-locals-gate.mjs`, `METRIC_BANDS`), и переводит лимит 200
браузерных мутантов из жёсткого в ориентир (`mutation-browser-policy.mjs`,
`mutation-registry-check.mjs`). Второй коммит правит найденный Validate дефект:
CLI `bundle-budget.mjs` проверял абсолютный бюджет раньше потолка беты, из-за
чего рост сверх полосы (`ceiling + band` выше `INITIAL_VIEW_GZIP_BUDGET`)
маскировался бюджетом и не был наблюдаем. Новый файл `scripts/ratchets.mjs` —
инструмент релиз-менеджера: `report [--warn]` (факт против потолков,
используется в `release:prerelease`) и `tighten` (потолки := факт, ручной шаг
на кандидате беты).
Владелец принял оба открытых вопроса ТЗ (issue-комментарий 2026-09-28): полоса
ядра = 50 строк, лимит браузерных мутантов становится ориентиром. Оба решения
корректно реализованы теми же числами, что названы в issue.
User-Visible: no на обоих коммитах, верно — `src/**` не тронут, продуктовое
поведение не меняется. Трейлеры `Issue: #699` и `User-Visible: no` на месте на
обоих коммитах.
## Как проверялось
Дешёвые гейты подтверждены зелёным Validate на этом SHA (`typecheck`,
`npm test`, `npm run build` + bundle-policy) — не перегонялись повторно.
Поверх этого, по диффу:
| Гейт | Прогнан | Результат |
|---|---|---|
| `node --test test/process-digests.test.mjs` | да | 5/5 ok — конспект `docs/process/REVIEWER.md` не разошёлся с PROCESS.md (диф этой задачи REVIEWER.md не трогает) |
| `node --test test/ratchets.test.mjs test/core-file-budget.test.mjs test/monolith-metrics.test.mjs test/mutation-gate.test.mjs test/bundle-assets.test.mjs` | да | 125/125 ok |
| `node scripts/ratchets.mjs report` (на закоммиченном `dist/`) | да | числа совпали один в один с таблицей из комментария автора: оба ядра и пять чисел монолита `равны факту`, четыре графа `рыхлые` на 550–960 Б |
| `node scripts/ratchets.mjs tighten` — в изолированном `git worktree` от этого SHA, с тем же `dist/` | да (ручная проверка ревьюером, исполнением) | опустил ровно четыре рыхлых потолка и `bundleBytes` до факта, ядра и точные метрики не тронул (файл не переписан, если число не изменилось); повторный `report` после этого показал «равен факту» по всем строкам |
| `node scripts/mutation-gate.mjs --check` | да | exit 0, без FAIL; `browser guards: 200/200`; 3 предсуществующих WARN не относятся к #699 (`#650`, `${…}` в паттернах) |
| `grep` литералов монолита в `test/ratchets.test.mjs` + `node --test test/monolith-text-anchors.test.mjs` | да | новый тест не задевает замороженный список `FROZEN_TEXT_ANCHOR_TESTS` (#624 AC4) — подтверждает, что первый упавший прогон `gate:small` автора («мой тест называл ядро строкой») действительно исправлен |
| чтение `scripts/ratchets.mjs`, `scripts/bundle-budget.mjs`, `scripts/monolith-metrics.mjs`, `scripts/unused-locals-gate.mjs`, `scripts/mutation-registry.mjs`, `scripts/mutation-registry-check.mjs`, `scripts/release-prerelease.mjs`, `PROCESS.md`, `docs/TESTING.md` целиком | да | см. находки и раздел «проверено корректно» |
**Не прогонялось и почему:** полный дифф-мутационный прогон (`mutation-gate.mjs`
с реальными патчами, 186 мутантов по заявлению автора) — трек show, Validate
уже зелёный на этом SHA, а структурная проверка реестра (`--check`) чистая;
браузерные смоки — тело issue их не называет, правка не трогает `src/**`,
`node scripts/smoke-select.mjs` не запускался, так как нет диффа по фронтенду,
который он мог бы сопоставить со смоками; `golden:verify`, `pytest
tests_backend`, `npm run invariants`, performance-профили — ни один не
применим (нет диффа по рендеру/Python/геометрии, AC их не называет).
## Находки
### Medium (в скоупе, чинится в этой же задаче)
**M1. Новый шаг «опустить потолки на бете» не попал в единственный канонический
release-runbook.** `docs/DEVELOPMENT.md` прямым текстом объявляет себя
единственным домом release-механики («This section is the only home of the
release mechanics»), и раздел «Primary prerelease path» — чек-лист, который
реально читает релиз-менеджер перед публикацией кандидата (синхронизировать
версии, changelog, `npm run bundle:release`, `RELEASE-NOTES.md`). В этом
чек-листе нет ни слова про `node scripts/ratchets.mjs tighten`, хотя весь
смысл #699 — в том, что «вторая сторона храповика» переехала именно сюда
(`PROCESS.md` §8, `scripts/ratchets.mjs`: «закоммитить вместе с кандидатом
беты»). Единственное место, где инструмент реально упомянут релиз-менеджеру —
`release-prerelease.mjs`, вызывающий `ratchets.mjs report --warn` **внутри
самой команды публикации**, то есть уже после того, как кандидат зафиксирован,
провалидирован и запрошена публикация. К этому моменту откатывать «один
коммит вместе с кандидатом» уже поздно — нужен отдельный коммит, а чек-лист
подготовки кандидата про это ничего не говорит.
*Воспроизведение:* `grep -n "ratchets" docs/DEVELOPMENT.md` — пусто; раздел
«Primary prerelease path» (`docs/DEVELOPMENT.md:516-560`) не содержит этого
шага. `release-prerelease.mjs:511` вызывает `ratchets.mjs report --warn`
только внутри `if (invokedDirectly)`, после `releaseView()` — то есть в момент
публикации, не подготовки.
*Почему это не мелочь:* без записи в реальном runbook шаг будет забываться
всегда, а не иногда — предупреждение видно только тому, кто уже нажал
публикацию, и не гейтится ничем (`--warn` всегда возвращает 0). Потолки
останутся рыхлыми навсегда, и половина заявленной пользы задачи (полоса
считается от факта беты, а не от старой точки) не реализуется на практике.
Это не гипотетический сценарий: на этом самом SHA `report` уже показывает
четыре рыхлых графа и `bundleBytes` — если следующая бета выйдет без ручного
запуска `tighten`, отсчёт полосы продолжится от чисел годичной давности.
*Что нужно:* добавить в чек-лист «Prepare the candidate as usual»
(`docs/DEVELOPMENT.md`, раздел «Primary prerelease path») шаг
`node scripts/ratchets.mjs tighten` перед коммитом кандидата — рядом с
`npm run bundle:release`, до пуша и Validate.
### Low (снято ревьюером)
**L1. Оркестрация `tighten` (ветка `command === 'tighten'` в
`scripts/ratchets.mjs`) не покрыта автотестом** — юниты покрывают только
чистые функции (`readCoreCaps`/`rewriteCoreCaps`, `readConst`/`rewriteConst`,
`ratchetState`, `ratchetRows`), а сама склейка (фильтрация строк по `kind`,
сборка `coreFacts`/`baseline`, порядок трёх `writeFileSync`) — нет. Снимаю
находку без возврата в задачу: проверил исполнением сам — в изолированном
`git worktree` от `709c5b8a` с тем же `dist/`, что и в дереве, `tighten`
опустил все четыре рыхлых графа и `bundleBytes`, не тронул уже точные ядра и
метрики монолита, и результат подтверждён повторным `report` (все строки —
«равен факту»). Это инструмент релиз-менеджера, запускаемый вручную раз в
бету, с диффом трёх файлов, который человек обязан просмотреть перед
коммитом (по инструкции инструмента) — риск непойманной регрессии невысокий и
несоразмерен цене автотеста, пишущего в реальные файлы гейтов. Если в
следующей бете `tighten` даст неверные числа — это будет видно в `git diff`
до коммита.
## Проверено и корректно
- Полоса включительна с обеих сторон корректно и симметрично на всех трёх
видах храповика: границы `cap+band`/`cap+band+1` для ядра, gzip-графов
(initial View и ленивых) и чисел монолита проверены отдельными тестами по
каждой стороне (`core-file-budget.test.mjs`, `bundle-assets.test.mjs`,
`monolith-metrics.test.mjs`) — прогнаны, зелёные.
- Снижение больше не красит ветку нигде из трёх мест
(`initialViewCeilingViolation`/`lazyGraphCeilingViolation` больше не имеют
ветки `kind: 'shrank'`; `compareWithBaseline`/`decide` печатают `info`, а не
`FAIL`) — старый текст «Опустите потолок» вычищен из кода и тестов везде,
где раньше проверялась двусторонность (проверено `grep` по всему дереву,
ни одного мёртвого упоминания старой ветки).
- Реордер в CLI `bundle-budget.mjs` (потолок беты проверяется до
`assertBundleBudget`) не создаёт второй источник числа: `result` из
`assertBundleBudget` и `manifest.initialViewGzipBytes`, использованный для
проверки потолка, — один и тот же непреобразованный byte-count из манифеста
(прочитано в исходнике: `assertBundleBudget` возвращает поля манифеста
насквозь, без пересчёта). Правка действительно чинит баг, названный в
сообщении Validate (`initial-view-ceiling-unplugged`): построил ветку в
изолированном чтении — старый порядок (бюджет раньше потолка) при
`ceiling+band` выше `INITIAL_VIEW_GZIP_BUDGET` действительно маскирует
проверку потолка бюджетом.
- Мутанты реестра (`scripts/mutation-registry.mjs`): 5 новых
(`core-band-ignored`, `initial-view-band-below-ceiling-again`,
`monolith-band-exact-again`, `monolith-shrink-fails-branch-again`,
`ratchet-report-calls-band-tight`) и один перенацеленный
(`monolith-metrics-baseline-strict`) — якоря найдены ровно по одному разу
(`mutation-gate.mjs --check` зелёный), guard-паттерны совпадают с
существующими именами тестов, `because` объясняет, какую регрессию мутант
ловит. Мутант, из-за которого автор чинил `bundle-budget.mjs` во втором
коммите (`initial-view-ceiling-unplugged`), обновлён на новое место якоря и
по-прежнему ловится тем же тестом.
- `mutation-registry-check.mjs`: превышение ориентира браузерных мутантов
теперь инкрементирует `warned`, а не `stale` — код выхода определяется
только `stale`, значит превышение больше не блокирует гейт, при этом
строка предупреждения печатается и называет способ обоснования — ровно то,
что решил владелец.
- Первый провал автора на `gate:small` (`#624 AC4`, «мой тест называл ядро
строкой») действительно исправлен: `test/ratchets.test.mjs` не содержит
литералов `houseplan-card.ts`/`houseplan-editor-runtime.ts`/
`houseplan-source.mjs`, `test/monolith-text-anchors.test.mjs` зелёный,
список `FROZEN_TEXT_ANCHOR_TESTS` не тронут.
- `PROCESS.md` §3 и §8, `docs/TESTING.md` описывают новое поведение
согласованно с кодом: полоса, кто её опускает, где стена (`
INITIAL_VIEW_GZIP_BUDGET` осталась абсолютной, это подтверждено и в CLI, и
в тексте).
- Число «потолок initial View» видно в тексте PROCESS.md/TESTING.md/ratchets.mjs
и в самом коде ровно с одним источником — `INITIAL_VIEW_GZIP_CEILING` в
`bundle-budget.mjs`; `ratchets.mjs` читает его оттуда же регэкспом, не
дублирует.
## Чего не проверял
- Полный дифф-мутационный прогон `mutation-gate.mjs` с реальными патчами (186
мутантов по заявлению автора, 6 шардов) — не перегонял; ограничился
структурной проверкой `--check` и точечными юнитами по изменённым файлам.
Трек show и зелёный Validate на этом SHA делают повторный полный прогон
избыточным для ревью.
- Браузерные смоки — не выбирались и не гонялись: диф не трогает `src/**` и
ничего в demo/, `smoke-select.mjs` не запускался за отсутствием диффа,
который он мог бы сопоставить.
- `golden:verify`, `pytest tests_backend`, `npm run invariants`,
performance-профили — не применимы к этому диффу (нет рендера, Python,
геометрии; AC их не называет).
- Не проверял, действительно ли `release:prerelease` печатает `--warn` в
реальном GitHub Actions прогоне публикации (только прочитал код и юнит
`test/ratchets.test.mjs`, который проверяет точную форму вызова текстом —
не исполнением всей команды `release:prerelease`).
## Вердикт
Один Medium в скоупе (M1) без High → жёлтый, возврат автору. Один Low (L1)
снят ревьюером с записью — проверен исполнением, дополнительной правки не
требует.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/699-ratchet-bands`, коммит `709c5b8a38f4` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `a18812c473f68a3fca17fb0b1b9df7909a219339`
```
git log --all --format='%H %T' | grep a18812c473f6
```
- Тело issue: `18b6f2dce7c2f914e9c4d61715360cb529fc34c87cbad12c2a646990fefcb724`
- Вердикт конвейера: `yellow` · High 0
+138
View File
@@ -0,0 +1,138 @@
# CODE-REVIEW-699-r2
Issue: #699 · этап: code · трек: show · заход: r2 · блокирующих циклов до этого раунда: 1/2
Материал раунда: `git log --oneline origin/dev..HEAD` / `git diff origin/dev...HEAD`,
вершина ветки `issue/699-ratchet-bands` = `23aca8a987142633c6f0c7522538d6601aa942d9`.
Validate на этом SHA — success (run 36490355420). Ребейз на `dev` не делался
(трек show, #696): `dev` впереди на 17 коммитов, слияние без конфликта
(заявлено автором, `git merge-tree`); материал — ветка как есть.
Предыдущий раунд: r1, документ `docs/reviews/CODE-REVIEW-699-r1.md`, материал
`709c5b8a38f4ac16f7ce18593ef0a40d672742cb`, вердикт жёлтый — один Medium в
скоупе (M1), High 0.
## Дельта r1 → r2
`git diff 709c5b8a..HEAD` — два коммита: `d7af9fbb` (только добавляет
`docs/reviews/CODE-REVIEW-699-r1.md`, документ ревью, не код) и `23aca8a9`
(исправление M1). Дифф второго коммита — три файла:
```
docs/DEVELOPMENT.md | 5 ++++-
scripts/mutation-registry.mjs | 11 +++++++++++
test/ratchets.test.mjs | 8 ++++++++
```
Разбор ниже — по этой дельте (§2.10): дельта локальна (один Medium из
предыдущего раунда, три файла), задача не меняет контракт и не вводит новую
подсистему, повторный ребейз не проводился.
## Закрытие раунда r1
| Находка | Чем закрыта | Где это видно |
|---|---|---|
| **M1**: `node scripts/ratchets.mjs tighten` не был в единственном каноническом release-runbook `docs/DEVELOPMENT.md` (§«Primary prerelease path»); единственное упоминание — предупреждение внутри `release:prerelease` уже в момент публикации, когда откатывать коммит кандидата поздно. | Шаг `tighten` вставлен в чек-лист «Prepare the candidate as usual» сразу после `npm run bundle:release` и до записи `docs/RELEASE-NOTES.md`, с пояснением «commit them with the candidate». Закрытие подкреплено тестом, который пере проверяет и наличие, и порядок шага, плюс новым мутантом реестра. | `docs/DEVELOPMENT.md:525-534` (см. ниже цитату); `test/ratchets.test.mjs:69-75` (`#699 r1 M1: runbook беты опускает храповики при подготовке кандидата, до публикации`); `scripts/mutation-registry.mjs` — `release-runbook-forgets-tighten`, guard `--test-name-pattern="#699 r1 M1"`. |
Цитата из `docs/DEVELOPMENT.md:525-531` (после правки):
> Prepare the candidate as usual: synchronize every version field, add dated RU
> and EN changelog sections, update the production bundle snapshots with
> `npm run bundle:release` (…), then lower the ratchets to the candidate's
> facts with `node scripts/ratchets.mjs tighten` (#699: it rewrites the core line
> caps, the gzip graph ceilings and `scripts/monolith-baseline.json` from the
> fresh `dist/`; commit them with the candidate) and write the short bilingual
> body in `docs/RELEASE-NOTES.md`.
Порядок ровно тот, который требовало замечание: `tighten` — после свежего
`dist/` (`bundle:release`) и до пуша/публикации (`release:prerelease`), одним
коммитом кандидата.
**L1** (r1, оркестрация `tighten` без автотеста) была снята ревьюером в r1 с
записью; дельта r2 её не касается — унаследовано без повторной проверки.
## Проверка исполнением (не только чтением)
- `node --test test/ratchets.test.mjs` — 6/6 ok, включая новый тест `#699 r1
M1`.
- Тест умеет падать: применил патч мутанта вручную (`tighten` → `report` в той
же позиции текста, ровно то, что делает `release-runbook-forgets-tighten`),
прогнал `node --test --test-name-pattern="#699 r1 M1" test/ratchets.test.mjs`
— **1 fail** (`assert.match` не находит `tighten` после `bundle:release`).
Откатил патч (`git status --porcelain` — чисто, `git diff` — пусто).
- `node scripts/mutation-gate.mjs --check` — зелёный; `release-runbook-forgets-tighten`
в выводе — ровно один раз `ok` (якорь не задваивается, реестр не
рассинхронизирован), `browser guards: 200/200`, 3 предсуществующих WARN не
относятся к #699 (те же, что в r1).
- `node --test test/mutation-gate.test.mjs test/monolith-text-anchors.test.mjs`
— 71/71 ok: новый тест не задевает замороженный список
`FROZEN_TEXT_ANCHOR_TESTS`, регресс первого провала автора (`#624 AC4`) не
вернулся.
- `node --test test/process-digests.test.mjs` — 5/5 ok (дифф r2 не трогает
`PROCESS.md`/`REVIEWER.md`, ожидаемо зелёный).
- Трейлеры коммита `23aca8a9`: `Issue: #699`, `User-Visible: no` — верно,
правка не трогает `src/**` и видимое поведение продукта не меняет (это
правка релиз-документации и внутреннего гейта). Оба changelog не тронуты —
корректно при `User-Visible: no`.
- Рабочая копия после ручной проверки мутанта осталась чистой:
`git status --porcelain` пуст, файлов в репозитории не создавал.
## Проверено и корректно (унаследовано из r1, без повторного прогона)
Не в скоупе дельты r2 — реализация правила «полоса над потолком беты» для
всех четырёх храповиков, реордер CLI `bundle-budget.mjs`, мутанты реестра,
согласованность `PROCESS.md`/`docs/TESTING.md` с кодом. Принято как в r1, без
повторной проверки — документ и материал: `docs/reviews/CODE-REVIEW-699-r1.md`,
SHA `709c5b8a38f4ac16f7ce18593ef0a40d672742cb`. Дельта r2 этих файлов не
касается (см. `git diff 709c5b8a..HEAD` выше — только `docs/DEVELOPMENT.md`,
`scripts/mutation-registry.mjs`, `test/ratchets.test.mjs`).
## Находки
Нет ни одной новой находки. M1 закрыт доказательно (тест + мутант,
исполнением подтверждено, что тест умеет падать). Новых Medium/Low в дельте не
обнаружено:
- Место вставки шага в чек-листе логически верное (после свежего `dist/`, до
публикации), формулировка не создаёт второго источника числа (шаг ссылается
на `#699`, не вводит новых констант).
- Мутант `release-runbook-forgets-tighten` вставлен в середину реестра без
видимых конфликтов id/anchor, гейт `--check` зелёный.
## Что не проверял
- Полный дифф-мутационный прогон (`mutation-gate.mjs` с реальными патчами,
186 мутантов, 6 шардов) — не перегонял повторно: трек show, Validate уже
зелёный на этом SHA, дельта r2 — три файла из документации/гейта, точечная
проверка нового мутанта исполнением сделана вручную (см. выше).
- Браузерные смоки — не выбирались и не гонялись: дельта не трогает `src/**`
и demo/, `smoke-select.mjs` не запускался за отсутствием диффа, который он
мог бы сопоставить.
- `golden:verify`, `pytest tests_backend`, `npm run invariants`,
performance-профили — не применимы к этому диффу (нет рендера, Python,
геометрии; AC их не называют).
- Не проверял исполнением, что реальный `release:prerelease` в GitHub Actions
печатает `--warn` — унаследовано из r1 как непроверенное (эта часть дельтой
r2 не затронута).
- Типизацию, полный `npm test` и `npm run build` с сверкой бандла — не
перегонял: зелёный Validate на этом же SHA (`23aca8a9`, run 36490355420)
подтверждает дешёвые гейты без повторного прогона.
## Вердикт
M1 из r1 закрыт: шаг `tighten` в единственном каноническом release-runbook,
до публикации, зафиксирован тестом, который умеет падать, и новым мутантом
реестра. Новых находок нет. High: 0, Medium: 0 → зелёный, цикл не тратится.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/699-ratchet-bands`, коммит `23aca8a98714` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `d7500b073db5a6a6c55130d61acfd14cd9f8c715`
```
git log --all --format='%H %T' | grep d7500b073db5
```
- Тело issue: `18b6f2dce7c2f914e9c4d61715360cb529fc34c87cbad12c2a646990fefcb724`
- Вердикт конвейера: `green` · High 0
+174
View File
@@ -0,0 +1,174 @@
# CODE-REVIEW-699-r3
Issue: #699 · этап: code · трек: show · заход: r3 · блокирующих циклов до этого раунда: 1/2
Материал раунда: `git log --oneline origin/dev..HEAD` / `git diff origin/dev...HEAD`,
вершина ветки `issue/699-ratchet-bands` = `4fa3c7977ebbd11907364af626f978deec790aa5`.
Validate на этом SHA — success (run 36491631358). Рабочая копия уже на этом SHA;
`git fetch`/`checkout` не делал.
Предыдущий раунд: r2, документ `docs/reviews/CODE-REVIEW-699-r2.md`, материал
`23aca8a987142633c6f0c7522538d6601aa942d9`, вердикт **зелёный** (High 0, Medium 0).
## Почему r3, если r2 был зелёным
Пока шло ревью r2, `dev` продвинулся на 17 коммитов (влиты #696–#698, #701,
#702). Владелец опубликовал кандидат `4fa3c797` как ребейз того же дерева на
новый `dev@e1700757` и сам заявил: «Дифф изменился при ребейзе… материал ревью
`23aca8a9` и кандидат `4fa3c797` дают разные patch-id: соседние правки в `dev`
изменили содержимое патча» (issue-комментарий 22:18:47, ссылка на §2.10, #492).
Зелёный вердикт цикл не тратит, но материал под ним умер (объекты `23aca8a9` и
дерево `d7500b073db5` не резолвятся в этом дереве — ожидаемо после ребейза,
`git cat-file -t` возвращает «not found», это не находка, см. REVIEWER.md
«Повторный раунд»), поэтому конвейер честно открыл новый заход, а не подтянул
старый вердикт под новый SHA (#312).
**Проверка гипотезы «дифф на самом деле не изменился».** Я не могу сравнить
деревья байт-в-байт (старые объекты собраны сборщиком мусора), поэтому сверил
по содержимому: прочитал целиком `git diff origin/dev...HEAD` (19 файлов,
856+/146-) и построчно сопоставил с описанием кода в r1/r2 (таблицы находок,
цитаты кода, номера тестов). Результат — по каждому файлу код и тесты
текстуально совпадают с тем, что описывали r1/r2:
`initialViewCeilingViolation`/`lazyGraphCeilingViolation` без ветки `shrank`,
`compareWithBaseline` с `METRIC_BANDS`, CLI `bundle-budget.mjs` с потолком
беты до `assertBundleBudget`, `mutation-registry-check.mjs` — `warned` вместо
`stale`, новый `scripts/ratchets.mjs` (`report`/`tighten`), шаг `tighten` в
`docs/DEVELOPMENT.md` («Prepare the candidate as usual», сразу после
`npm run bundle:release`) — ровно формулировка, которую цитировал r2. Значит
patch-id разошёлся не из-за смыслового отличия, а из-за смещения контекстных
строк при повторном ребейзе (PROCESS.md/докс, которые правили #701 и другие
соседи, лежат рядом с местом вставки #699). Разбор ниже — по этой дельте: она
не локальна формально (ребейз на ушедший вперёд `dev`, §2.10), поэтому я
перечитал файлы целиком и перепрогнал тесты сам (см. «Как проверялось»), а не
ограничился таблицей «чем закрыта» — но новых находок это не даёт, потому что
менять было нечего.
## Скоуп
Не изменился с r1 (см. `docs/reviews/CODE-REVIEW-699-r1.md`, «Скоуп»):
инфраструктурная задача, файлов класса A нет, `src/**` не тронут. Двусторонние
храповики с нулевым запасом заменены на полосу над потолком последней беты
(строки двух ядер, gzip четырёх графов, шесть чисел монолита, лимит браузерных
мутантов из жёсткого — в ориентир); `scripts/ratchets.mjs` — инструмент
релиз-менеджера (`report`/`tighten`); шаг `tighten` закреплён в единственном
каноническом release-runbook (`docs/DEVELOPMENT.md`) — это и есть M1 из r1,
закрытый в r2. Оба открытых вопроса ТЗ решены владельцем (полоса ядра = 50,
лимит мутантов — ориентир) и реализованы теми же числами.
`User-Visible: no` на всех четырёх коммитах (`47bdf883`, `44d4ff4f`, `56ebddf3`
и двух документных `61feca1f`/`bc15c3c9`/`4fa3c797`) — верно, продуктовое
поведение (`src/**`) не меняется; changelog не тронут, и это корректно при
`no` (проверено — `git diff origin/dev...HEAD --stat -- docs/CHANGELOG.md
docs/CHANGELOG.ru.md` пуст). Трейлеры `Issue: #699` / `User-Visible: no`
подтверждены на всех трёх кодовых коммитах (`git show -s --format=... | grep`).
## Как проверялось
Дешёвые гейты подтверждены зелёным Validate на этом же SHA (`4fa3c797`, run
36491631358) — `typecheck`, `npm test`, `npm run build` + bundle-policy не
перегонялись повторно. Поверх этого, по диффу и лично мной на этом SHA:
| Гейт | Прогнан | Результат |
|---|---|---|
| `node --test test/ratchets.test.mjs test/core-file-budget.test.mjs test/monolith-metrics.test.mjs test/bundle-assets.test.mjs test/mutation-gate.test.mjs test/process-digests.test.mjs test/monolith-text-anchors.test.mjs` | да, лично | 133/133 ok |
| `node scripts/mutation-gate.mjs --check` | да, лично | зелёный; `browser guards: 200/200`; 3 предсуществующих WARN (не относятся к #699, те же, что в r1/r2) |
| Защитный AC M1 — «тест умеет падать» перепроверен на этом SHA, не только унаследован | да, лично, исполнением | заменил в `docs/DEVELOPMENT.md` `tighten` → `report` в той же позиции (патч мутанта `release-runbook-forgets-tighten`), `node --test --test-name-pattern="#699 r1 M1" test/ratchets.test.mjs` → **1 fail** (`assert.match` не находит `tighten` после `bundle:release`), откатил (`git checkout -- docs/DEVELOPMENT.md`), `git status --porcelain` — пусто |
| Построчное сопоставление `git diff origin/dev...HEAD` (19 файлов) с описанием кода в r1/r2 | да, лично, чтением | текст диффа совпадает с цитатами и таблицами r1/r2 файл за файлом — см. «Почему r3» выше |
| `git diff origin/dev...HEAD --stat -- docs/CHANGELOG.md docs/CHANGELOG.ru.md` | да | пусто — корректно при `User-Visible: no` |
| Трейлеры `Issue`/`User-Visible` на `47bdf883`, `44d4ff4f`, `56ebddf3` | да, лично | все три — `Issue: #699`, `User-Visible: no` |
| `docs/reviews/INDEX.md` (r1/r2 уже проиндексированы) | да, прочитал | строки #699 r1 (🟡, M1) и r2 (🟢) на месте, согласуются с документами |
**Не прогонялось и почему:** полный дифф-мутационный прогон (`mutation-gate.mjs`
с реальными патчами, 186 мутантов) — трек show, зелёный Validate на этом SHA,
структурная `--check` чистая, и содержимое диффа не изменилось с r1/r2, где
этот прогон уже проводился (186/186); браузерные смоки — тело issue их не
называет, диф не трогает `src/**`/`demo/`, `smoke-select.mjs` не запускался за
отсутствием сопоставимого диффа; `golden:verify`, `pytest tests_backend`,
`npm run invariants`, performance-профили — не применимы (нет рендера, Python,
геометрии; AC их не называют); не проверял исполнением реальный
`release:prerelease` в GitHub Actions — унаследовано из r1/r2 как непроверенное
(эта часть дельтой r3 не затронута); `npx tsc --noEmit`/`npm test` целиком/
`npm run build` с трёхсторонней сверкой бандла — не перегонял: зелёный Validate
на этом же SHA подтверждает дешёвые гейты без повтора.
## Закрытие раунда r1
| Находка | Чем закрыта | Где это видно |
|---|---|---|
| **M1**: `node scripts/ratchets.mjs tighten` не был в единственном каноническом release-runbook `docs/DEVELOPMENT.md`; единственное упоминание — предупреждение внутри `release:prerelease` уже в момент публикации. | Шаг `tighten` в чек-листе «Prepare the candidate as usual» сразу после `npm run bundle:release`, до `docs/RELEASE-NOTES.md`/публикации; тест `#699 r1 M1` пере-проверяет и наличие, и порядок; мутант `release-runbook-forgets-tighten`. Перепроверено мной на `4fa3c797`: мутант применён вручную → тест падает (1 fail) → откат чистый. | `docs/DEVELOPMENT.md:525-534` (`Prepare the candidate as usual` → `bundle:release` → `ratchets.mjs tighten (#699 …) commit them with the candidate` → `RELEASE-NOTES.md`); `test/ratchets.test.mjs:69-75`; `scripts/mutation-registry.mjs` — `release-runbook-forgets-tighten`. |
Низкая находка **L1** (r1, оркестрация `tighten` без автотеста) была снята
ревьюером в r1 с записью (проверено исполнением в изолированном worktree);
дельта r2 и r3 её не касаются.
## Унаследовано из r1/r2 (без повторной проверки по существу)
Симметричность полосы по обеим границам на всех трёх видах храповика (ядра,
gzip-графы, числа монолита), корректность реордера в CLI `bundle-budget.mjs`
(потолок беты проверяется до `assertBundleBudget`, один источник байт-каунта),
пять новых мутантов реестра + один перенацеленный (якоря найдены по одному
разу), согласованность `PROCESS.md §8`/`docs/TESTING.md` с кодом, «одно число —
один источник» у потолка initial View (`INITIAL_VIEW_GZIP_CEILING` в
`bundle-budget.mjs`, `ratchets.mjs` читает его же регэкспом) — всё это разобрано
полно в `docs/reviews/CODE-REVIEW-699-r1.md` (материал `709c5b8a38f4`) и не
затронуто дельтой r1→r2→r3 текстуально (см. «Почему r3» выше — построчное
сопоставление). Принимаю без повторного вывода, кроме того, что перепроверил
сам и назвал в таблице выше (юниты, `mutation-gate --check`, M1-мутант).
## Находки
Новых находок нет. High: 0, Medium: 0.
Формальный повод для r3 (смена patch-id при повторном ребейзе, #492) не
породил содержательного отличия: диф `origin/dev...HEAD` на этом SHA
текстуально совпадает с тем, что рецензировали r1 и r2, тесты и мутационный
гейт зелёные лично на этом SHA, а M1 остаётся доказуемо закрытым (мутант
всё ещё ловится).
## Проверено и корректно
- Продуктовый код (`src/**`) не тронут — задача инфраструктурная, `User-Visible:
no` верно на всех коммитах, changelog не трогается, и это правильно.
- Рабочая копия чистая до и после моей проверки мутанта (`git status
--porcelain` — пусто оба раза), файлов в репозитории я не создавал (документ
ревью пишется вне репозитория, в `$REVIEW_DOC`).
- Тесты, гейты и трейлеры на текущем SHA `4fa3c797` дают тот же результат, что
описывали r1/r2 на своих (уже нерезолвящихся) SHA — расхождения нет.
## Чего не проверял
- Полный дифф-мутационный прогон `mutation-gate.mjs` с реальными патчами (186
мутантов, 6 шардов) — не перегонял на этом SHA: он уже был выполнен в r1 на
байт-идентичном по содержанию диффе, трек show, Validate зелёный.
- Браузерные смоки — диф не трогает `src/**`/`demo/`, `smoke-select.mjs` не
запускался за отсутствием сопоставимого диффа.
- `golden:verify`, `pytest tests_backend`, `npm run invariants`,
performance-профили — не применимы (нет рендера, Python, геометрии; AC их не
называют).
- Исполнение реального `release:prerelease` в GitHub Actions (печатает ли
`--warn` на практике) — унаследовано из r1/r2 как непроверенное.
- `npx tsc --noEmit`, `npm test` целиком, `npm run build` с трёхсторонней
сверкой бандла — не перегонял: зелёный Validate на `4fa3c797` (run
36491631358) подтверждает дешёвые гейты без повтора.
## Вердикт
Формальный r3 без содержательной дельты: рёбейз сменил patch-id, но не код.
M1 из r1 остаётся закрытым и перепроверен исполнением на текущем SHA. Новых
находок нет. High: 0, Medium: 0 → зелёный, цикл не тратится.
---
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/699-ratchet-bands`, коммит `4fa3c7977ebb` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `3dfcbd4230f3cf2b19d814176b2b5887dbd30564`
```
git log --all --format='%H %T' | grep 3dfcbd4230f3
```
- Тело issue: `18b6f2dce7c2f914e9c4d61715360cb529fc34c87cbad12c2a646990fefcb724`
- Вердикт конвейера: `green` · High 0
+184
View File
@@ -0,0 +1,184 @@
# CODE-REVIEW-700-r1
Issue: #700 · Предполёт не красит ветку задачи чужими причинами: зеркало workflow и внешние ссылки
Этап: code · Заход: r1 · Трек: show · Материал: `89d57e054cdf940d3ead83ec509075164118ee3b` (один коммит поверх `origin/dev` @ `c716bb0f63104f5afb9d660bc058e095256d433d`)
## Скоуп
Инфраструктурная задача трека `show`: предполёт `validate.yml` не должен красить
ветку задачи двумя внешними по отношению к её диффу причинами —
1. расхождением зеркала тонких вызывающих workflow (`main` vs `dev`);
2. упавшим внешним сайтом при проверке ссылок в документации (`check-docs.mjs`).
Оба пункта на ветках `issue/*` становятся предупреждением в сводке, не красят
вердикт; на push в `dev` (там, где кандидат беты и релиз фактически и
проверяются — см. ниже) поведение остаётся прежним, красным. Плюс — на push в
`dev` расхождение зеркала автоматически заводит одно owner-issue
`[workflow-sync]` (или комментирует уже открытое), по образцу ночного
мутационного гейта (#472).
Файлы: `.github/workflows/validate.yml`, `scripts/check-docs.mjs`,
`scripts/mutation-registry.mjs`, `PROCESS.md` §10.4,
`test/validate-workflow.test.mjs`, `test/docs-freshness.test.mjs`,
`test/classify-changes.test.mjs`. Файлов класса A/D нет — согласен с автором.
## Как проверялось
Дешёвые гейты (typecheck/test/build/bundle-policy) подтверждены зелёным
Validate на этом же SHA (workflow_dispatch, `issue/700-preflight-warnings`,
run `36482200722`, `conclusion: success`) — не перегонялись повторно.
Дополнительно к зелёному Validate прогнал сам:
| Гейт | Результат |
|---|---|
| `node --test test/validate-workflow.test.mjs` | 27/27 зелёных |
| `node --test test/classify-changes.test.mjs` | 21/21 зелёных |
| `node --test test/docs-freshness.test.mjs` | 3/3 зелёных |
| `node --test test/mutation-gate.test.mjs` (структура реестра + исполнение guard-команд обоих новых мутантов среди прочих) | 69/69 зелёных |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | «Исполняемого frontend-диффа нет (`src/**/*.ts` не тронут)» — браузерные смоки нечего выбирать, не пропуск |
| Мутация вручную: `advise` → `check` в `.github/workflows/validate.yml`, тест `#700: на ветке задачи…` | падает (`1 fail`), восстановлено |
| Мутация вручную: `externalErrors = EXTERNAL_WARN ? warnings : errors` → `= errors` в `scripts/check-docs.mjs`, тест `#700: check-docs…` и `docs-freshness.test.mjs` | оба падают, восстановлено |
Также прочитал живой лог `run 36482200722`, job «Предполёт»: на этом прогоне
`REF=refs/heads/issue/700-preflight-warnings` реально прошёл веткой
`--external=warn` (`скриншоты документации: режим warn` →
`Documentation checks passed (7 files, 12 external links)`), а шаг «Вердикт
предполётных проверок» реально выполнил `task_branch=true` и напечатал
`ok тонкие вызывающие workflow в main и dev` через `advise` (все шесть
файлов зеркала совпали, поэтому и `advise`, и `check` в этом прогоне дали бы
одинаковый «ok» — само разветвление логики живьём не проверено на красном
входе, это закрыто юнит-тестами и ручной мутацией выше). Шаг «Расхождение
зеркала на dev — issue владельцу» на этом прогоне не выполнялся (условие
`push && refs/heads/dev` не совпало) — ожидаемо, не тестировался живьём,
только юнит-тестом на структуру шага.
## Находки
### Medium (в скоупе) — `gh issue list` fail-open вместо fail-safe в новом шаге создания issue
`.github/workflows/validate.yml`, шаг «Расхождение зеркала на dev — issue
владельцу»:
```bash
existing=$(gh issue list --repo "$REPO" --state open --search "\"$marker\" in:title" \
--json number,title --jq '[.[] | select(.title | startswith("[workflow-sync]"))][0].number // empty' || true)
if [ -n "$existing" ]; then
gh issue comment "$existing" ...
exit 0
fi
gh issue create --repo "$REPO" --title "$marker ..." ...
```
`|| true` глушит именно **чтение** списка issue: если `gh issue list` падает
(сетевой сбой, рейт-лимит), `existing` остаётся пустой строкой, и скрипт идёт
в ветку `gh issue create` — заводит новое `[workflow-sync]` issue, даже если
открытое уже есть. Это расходится и с заявленным контрактом задачи («если оно
уже открыто, в него добавляется комментарий»/PROCESS.md §10.4 «одно issue»),
и с намерением автора («Сбой API — предупреждение: шаг сообщает, а не
судит») — фактическое поведение при сбое чтения не «сообщает», а действует
(плодит дубликат).
Сравнение с прецедентом, на который автор явно ссылается (#472,
`.github/workflows/_mutation-gate.yml:327`): та же операция там **без**
`|| true` — `gh issue list` без защиты, при её падении шаг GitHub Actions
(shell по умолчанию `bash -eo pipefail`) прерывается целиком, дубликат не
заводится. Новый шаг в #700 сознательно ослабил именно эту защиту.
**Чем краснеет:** мутация `|| true` → убрать (или заменить на
`|| { echo "::warning::не удалось проверить открытые issue — issue не
заводится"; exit 0; }`) — тест на этот путь в диффе отсутствует, поэтому
проверено чтением, не исполнением; воспроизвести можно, подменив `gh` в PATH
шага на скрипт, который для `issue list` возвращает ненулевой код, — тогда
следующий `gh issue create` реально уйдёт, хотя нужного issue список не нашёл
не потому, что его нет, а потому что сам не смог проверить.
**Почему Medium, не High:** окно узкое (нужен сбой именно у `gh issue list`,
а не у последующих команд), шаг не участвует в вердикте (`check`/`advise` его
исход не читают), последствие — лишнее owner-facing issue, а не красный
Validate и не пропущенное реальное расхождение. Но это дефект поведения,
прямо противоречащий описанному в задаче и в PROCESS.md контракту «одно
issue», поэтому не Low: правка тривиальна (одна строка) и находится в этом
же диффе, значит чинится в этом же issue (§3 п.8, §2.7), не заводится
отдельно.
## Что проверено и корректно
- **Блокирующий периметр не сузился там, где должен остаться красным.**
«Кандидат беты» в этом репозитории — не ветка `issue/*`, а коммит с
трейлером `Release:` (PROCESS.md, класс D) либо `workflow_dispatch` тега в
`release.yml`, оба целятся в `dev`/точный SHA, не в `issue/*`-ветку — значит
`task_branch=false`, и `check` (красный) для workflow_sync и `--external`
(без `warn`) для внешних ссылок продолжают действовать на этих путях.
Отдельно проверил: «кандидат слияния» (`merge-candidate.mjs`) действительно
публикуется push’ем в саму ветку задачи (PROCESS.md §… «публикация
кандидата в ветку задачи») и там расхождение зеркала будет предупреждением —
но финальный гейт слияния всё равно упирается в push **в `dev`** с
`--force-with-lease`, а этот push уже не `issue/*`, и там проверка красная
по-прежнему. Сквозной дыры в периметре нет.
- `validate.yml` не входит в список из шести тонких зеркалируемых файлов
(`process.yml`, `mutation-gate.yml`, `process-resume.yml`, `nightly.yml`,
`process-reconcile.yml`, `process-metrics.yml`,
`test/default-branch-workflows.test.mjs`) — этому диффу зеркалирование в
`main` не требуется, сам он не самозеркалируемый файл.
- `check-docs.mjs`: `EXTERNAL_WARN` корректно разводит `externalErrors` на
`warnings`/`errors`, не создавая двойного пуша в оба стока; выход по
`errors.length` не тронут — предупреждения никогда не красят `exitCode`.
`transientHosts` (уже существовавший путь всегда-предупреждение) не задет.
- Трейлеры коммита: `Issue: #700`, `User-Visible: no` — корректно, поведение
для конечного пользователя карточки не меняется (это только CI/процесс),
изменений в `docs/USER-GUIDE.ru.md` не требуется и не сделано.
- Одно число, один источник (§8): числовых значений, видимых пользователю
карточки, дифф не вводит и не дублирует — весь дифф про CI-вердикт.
- Тесты умеют падать: обе новые мутации в `scripts/mutation-registry.mjs`
(`task-branch-workflow-sync-red-again`, `external-link-warn-mode-ignored`)
проверены вручную — соответствующие guard-тесты красные на мутированном
коде, зелёные на исходном (см. таблицу гейтов выше).
- `PROCESS.md` §10.4 дополнен точно тем, что реализовано: явно назван список
из шести файлов, явно — что `performance.yml` в него не входит (не
тронуто), явно — что на ветке задачи это предупреждение, а блокирует
`dev`/кандидат/релиз.
## Чего не проверял
- `actionlint validate.yml` — заявлен автором, локально бинарник недоступен
(`command not found`); не перепроверял. Косвенное подтверждение — сам YAML
реально исполнился на CI (run `36482200722`) без ошибки парсинга workflow.
- `mutation-gate --check` (полный) и весь `npm run gate:small` — не
перегонял: дешёвые гейты уже зелёные на этом SHA (см. ссылку в задаче
ревью), а трек `show` не запрашивает мутанты по диффу (#696) — их
отсутствие в этом отчёте не находка.
- Ветвь `pull_request`/иных не-`issue/*`, не-`dev` рефов (например,
гипотетический feature-branch без префикса `issue/`) — в этом репозитории
такие пуши процессом не предусмотрены (`pre-push` гейт + прямые пуши,
PR-флоу не используется по факту виденных коммитов), поэтому не разбирал
отдельно; если он всё же встретится, `task_branch=false` и поведение
останется прежним (строгим) — то есть ничего не ослабляется по умолчанию.
- Живое воспроизведение красного `workflow_sync` (реальное расхождение
`main`/`dev`) и живое исполнение шага создания issue — не запускал (это
разрушительное действие: реально завело бы issue в репозитории); логика
закрыта юнит-тестами и ручными мутациями, шаг создания issue — только
чтением кода плюс находкой выше.
- `golden:verify`, `pytest tests_backend`, инварианты модели, performance —
не применимо, дифф не трогает `src/**`, рендер, геометрию или Python.
## Вердикт
Единственная находка — Medium, в скоупе задачи, с указанной строкой и
минимальной правкой. По правилам трека без High это жёлтый вердикт и возврат
автору в этом же issue.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/700-preflight-warnings`, коммит `89d57e054cdf` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `945dad8823268ab0bbce926bd9ffe5e71d1f6cb2`
```
git log --all --format='%H %T' | grep 945dad882326
```
- Тело issue: `a0c40d40c1401844c3d04ff0695dd347e76172d990004ea2799cc4df38729fce`
- Вердикт конвейера: `yellow` · High 0
+148
View File
@@ -0,0 +1,148 @@
# CODE-REVIEW-700-r2
Issue: #700 · Предполёт не красит ветку задачи чужими причинами: зеркало workflow и внешние ссылки
Этап: code · Заход: r2 · Трек: show · Материал: `ed3d63e20db24fe9172c83f7ea2e824c90444dcc`
(коммит `ed3d63e2` поверх материала r1 `89d57e054cdf940d3ead83ec509075164118ee3b`,
который сам лежит поверх `origin/dev` @ `c716bb0f63104f5afb9d660bc058e095256d433d`;
на момент этого раунда `origin/dev` ушёл на 8 коммитов вперёд, слияние без
конфликта — по треку `show` ветка к `dev` не приводится, материал — ветка как есть).
## Скоуп раунда
Единственная цель r2 — закрыть единственную находку r1 (Medium, в скоупе):
`gh issue list … || true` в шаге «Расхождение зеркала на dev — issue
владельцу» (`.github/workflows/validate.yml`) глушил сбой чтения списка
открытых issue в пустую строку и проваливался в `gh issue create`, заводя
дубликат `[workflow-sync]` даже когда нужное issue уже открыто, но недоступно
для чтения (сеть, рейт-лимит).
Дельта r1→r2 — ровно один коммит `ed3d63e2`, три файла:
```
.github/workflows/validate.yml | 9 +++++++--
scripts/mutation-registry.mjs | 11 +++++++++++
test/validate-workflow.test.mjs | 3 +++
```
Дельта локальна и пропорциональна находке (одна правка на одну строку внутри
уже одобренного шага): AC, которые она задевает, — только тот же самый AC про
«одно issue, не дубликат» из тела #700 и контракта PROCESS.md §10.4. Остальной
диапазон (`check-docs.mjs`, `PROCESS.md`, `test/classify-changes.test.mjs`,
`test/docs-freshness.test.mjs`) не тронут этим коммитом — соответствующая
часть r1 наследуется без повторной проверки (раздел ниже).
## Как проверялось
Дешёвые гейты подтверждены зелёным Validate на этом же SHA `ed3d63e2`
(workflow_dispatch, run `36484635253`, `conclusion: success`, ссылка дана в
задаче ревью) — `npx tsc --noEmit`, `npm test`, `npm run build` +
`bundle-policy --verify` повторно не гонял.
Дополнительно к зелёному Validate прогнал сам:
| Гейт | Результат |
|---|---|
| `node --test test/validate-workflow.test.mjs` | 27/27 зелёных, включая изменённый `#700: на ветке задачи…` |
| `node --test test/classify-changes.test.mjs` | 21/21 зелёных (не тронут этим коммитом, для очистки дельты) |
| `node --test test/mutation-gate.test.mjs` | 69/69 зелёных, структура нового мутанта `workflow-sync-issue-duplicated-on-read-failure` валидна |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | «Исполняемого frontend-диффа нет» — src/**/*.ts не тронут, браузерные смоки нечего выбирать |
| Мутация вручную из реестра: `exit 0` → `:` в новом `if`-блоке (patch `workflow-sync-issue-duplicated-on-read-failure`), тест `--test-name-pattern="#700: на ветке задачи"` | падает (1 fail, `assert.doesNotMatch` на `|| true)` и/или `assert.match` на новый `if !`-блок), файл восстановлен из бэкапа, `git status` чист |
Живое воспроизведение реального сбоя `gh issue list` на CI не запускал (это
живой internal API-вызов на push в `dev`, вне доступа из этой сессии, и это
разрушительное действие, если сорвётся не так, как задумано) — как и в r1,
логика закрыта юнит-тестом плюс ручной мутацией guard-команды выше.
## Находки
Нет. Правка r2 точна: устраняет ровно описанный в r1 путь, не расширяет и не
сужает ничего другого в шаге.
## Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| Medium: `gh issue list … \|\| true` — сбой чтения списка открытых issue проваливается в `gh issue create`, заводя дубликат `[workflow-sync]` вместо предупреждения | `\|\| true` убран; чтение обёрнуто в `if ! existing=$(gh issue list …); then echo "::warning::…"; exit 0; fi` — при ненулевом коде `gh issue list` шаг предупреждает и выходит, не доходя ни до `gh issue comment`, ни до `gh issue create` | `.github/workflows/validate.yml` (коммит `ed3d63e2`, блок шага «Расхождение зеркала на dev — issue владельцу»); поведение зафиксировано тестом `test/validate-workflow.test.mjs:628-633` (два новых `assert`: на форму `if !…then…exit 0…fi` и на отсутствие `\|\| true)` до `gh issue create`) и мутантом `workflow-sync-issue-duplicated-on-read-failure` в `scripts/mutation-registry.mjs`, который я применил вручную — тест краснеет на мутированном коде и зеленеет на исходном (таблица гейтов выше) |
## Унаследовано из r1
Принято без повторной проверки в этом раунде — документ и материал:
`docs/reviews/CODE-REVIEW-700-r1.md` (в дереве `545be665`), материал
`89d57e054cdf940d3ead83ec509075164118ee3b`, дерево материала `945dad882326…`:
- Периметр `task_branch=true/false` не сузился: `check`/`advise` для
workflow_sync и `--external`/без `warn` для внешних ссылок остаются красными
для push в `dev` и для кандидата беты/релиза; предупреждением становится
только ветка задачи.
- `validate.yml` не входит в список из шести тонких зеркалируемых файлов —
этому диффу зеркалирование в `main` не требуется.
- `check-docs.mjs`: `EXTERNAL_WARN` корректно разводит `warnings`/`errors`,
выход по `errors.length` не тронут.
- Трейлеры коммита r1 (`Issue: #700`, `User-Visible: no`) — корректны, диффу
класса A/D нет.
- `PROCESS.md` §10.4 дополнен точно тем, что реализовано в r1.
- Оба мутанта r1 (`task-branch-workflow-sync-red-again`,
`external-link-warn-mode-ignored`) — краснеют на мутации, зеленеют на
исходнике (проверено в r1 вручную).
- `actionlint` не перепроверялся (недоступен локально и в r1, и сейчас);
косвенно — YAML реально исполнился на CI (run r1 `36482200722` и run r2
`36484635253`) без ошибки парсинга.
## Что проверено и корректно (r2, сверх наследования)
- Новый `if`-блок синтаксически и семантически корректен для `bash -eo
pipefail` (шелл GitHub Actions по умолчанию): присваивание внутри условия
`if !` не триггерит `set -e`, а код возврата, который проверяется, — код
возврата `gh issue list` (json/jq считает сам `gh`, не отдельный процесс в
пайпе), так что сетевой сбой или рейт-лимит действительно попадает в ветку
`if`, а не проскакивает мимо неё.
- Ветка «не прочитано → предупреждение и выход» физически предшествует и
`gh issue comment`, и `gh issue create` — при сбое чтения оба этих вызова
теперь не выполняются вообще, то есть дубликат исключён, а не просто
переименован в другую ошибку.
- Комментарий в коде (`# Список не прочитан — …`) объясняет неочевидную
причину (почему это не «оставить || true»), а не пересказывает код — по
стилю совпадает с уже принятыми в r1 комментариями этого же шага.
- Новый мутант в реестре (`workflow-sync-issue-duplicated-on-read-failure`)
минимален и целится ровно в закрытый путь (`exit 0` → `:`, единственная
правка, из-за которой шаг снова провалился бы в `if [ -n "$existing" ]`
с пустым `$existing`).
- Изменение не расширяет и не меняет скоуп задачи: файлов класса A/D нет,
видимое поведение карточки не меняется, `docs/USER-GUIDE.ru.md` не
требует правок.
- Одно число, один источник (§8): числовых значений, видимых пользователю
карточки, дифф r2 не вводит.
## Чего не проверял
- `mutation-gate --check` (полный прогон, а не структура) и весь `npm run
gate:small` — дешёвые гейты уже подтверждены зелёным Validate на этом SHA,
трек `show` не запрашивает мутанты по диффу целиком (#696).
- Живое исполнение шага «Расхождение зеркала на dev — issue владельцу» с
реально упавшим `gh issue list` на push в `dev` — не воспроизводил (нужен
реальный сбой GitHub API или подмена `gh` внутри Actions-раннера, недоступно
из этой сессии); логика закрыта юнит-тестом плюс ручной мутацией, как в r1.
- `golden:verify`, `pytest tests_backend`, инварианты модели, performance —
неприменимо, дифф всего диапазона не трогает `src/**`, рендер, геометрию или
Python (подтверждено ещё в r1, r2 диапазон это не меняет).
## Вердикт
Единственная находка r1 закрыта точной правкой с тестом и проверенным
мутантом; новых находок нет.
---
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/700-preflight-warnings`, коммит `ed3d63e20db2` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `9a8052d18a3baa6cadd0907021a3a01dfc11d935`
```
git log --all --format='%H %T' | grep 9a8052d18a3b
```
- Тело issue: `a0c40d40c1401844c3d04ff0695dd347e76172d990004ea2799cc4df38729fce`
- Вердикт конвейера: `green` · High 0
+103
View File
@@ -0,0 +1,103 @@
# CODE-REVIEW-700-r4
## Материал раунда
- Issue: [#700](https://github.com/Matysh/houseplan-card/issues/700), трек `track:show`, инфраструктурный маршрут (класс B: `.github/workflows/**`, `scripts/**`, `test/**`; класс C: `PROCESS.md`, `docs/reviews/**`). Файлов класса A нет.
- SHA материала: **`1aa52d21071705574c6cccb1f19ccc3a8d5b9083`** (рабочая копия уже на нём, `git status` чист).
- Диапазон: `git log --oneline origin/dev..HEAD` — 4 коммита:
`e45bc87c` (задача, r0) → `a005aae5` (докс r1) → `f6e317d8` (задача, правка Medium из r1) → `1aa52d21` (докс r2).
- `git diff origin/dev...HEAD --stat`: `validate.yml` +66/-12, `PROCESS.md` +7/-1, `scripts/check-docs.mjs` +12/-1, `scripts/mutation-registry.mjs` +34, три тестовых файла, два committed документа ревью (`CODE-REVIEW-700-r1.md`, `-r2.md`).
- Validate на этом SHA: **success**, https://github.com/Matysh/houseplan-card/actions/runs/36492360526 — дешёвые гейты (`tsc --noEmit`, `npm test`, `npm run build` + сверка бандла) подтверждены этим прогоном, повторно не гонял.
## Почему разбор полный, а не по дельте (§2.10)
Формально с r2 (доказанно зелёный, `docs/reviews/CODE-REVIEW-700-r2.md`) код задачи не менялся ни байтом: r3 применил тот же вердикт повторно без вызова модели, потому что дерево `a6670a47` совпадало с проверенным. Но между r3 и r4 ветку **пришлось перебазировать** на ушедший на 11 коммитов вперёд `dev` (`e1700757`) — push кандидата рвался токеном конвейера без права `workflow` (#705), и автор сам пометил: «дифф против `dev` сменил контекст `validate.yml` … ожидаю новый заход ревью, а не повтор вердикта r2». Это прямо описанное в задании исключение — «ребейз на ушедший вперёд `dev`» — где разбор остаётся полным. Сделал полный разбор: перечитал итоговый `preflight` job целиком (не только хунки диффа), проверил, как правка `#700` сочетается с соседними правками `dev` (`#696` — цена захода по треку/мутанты по диффу; `#697` — режим скриншотов на ветке задачи), и заново прогнал целевые тесты и три зарегистрированных мутанта.
## Как проверялось
1. Прочитан весь `.github/workflows/validate.yml` job `preflight` в его текущем виде (после ребейза), не только диф-хунки — искал конфликт правки `#700` с окружающим контекстом от `#696`/`#697`.
2. Прочитан `scripts/check-docs.mjs` целиком вокруг флага `--external`/`--external=warn` и путь `errors`/`warnings`/`process.exitCode`.
3. Прочитаны все три тестовых диффа (`test/validate-workflow.test.mjs`, `test/docs-freshness.test.mjs`, `test/classify-changes.test.mjs`) и правка `PROCESS.md`.
4. Прогнаны целевые юниты: `node --test test/validate-workflow.test.mjs test/docs-freshness.test.mjs test/classify-changes.test.mjs` — 52/52 зелёных.
5. Для каждого из трёх мутантов `#700` в `scripts/mutation-registry.mjs` (`task-branch-workflow-sync-red-again`, `workflow-sync-issue-duplicated-on-read-failure`, `external-link-warn-mode-ignored`) применил патч руками, перезапустил соответствующий `--test-name-pattern`, убедился, что тест краснеет, откатил файл (`git status` после — чист). Это не требуется на `track:show` (мутанты по диффу не запрашиваются), но дёшево и напрямую проверяет дисциплину «тест умеет падать» для тестов, которые я использую как доказательство.
6. `node scripts/smoke-select.mjs --base origin/dev --head HEAD` → «Исполняемого frontend-диффа нет (`src/**/*.ts` не тронут)» — прямое совпадение «нечего выбирать», не решение пропустить.
7. Сверил трейлеры всех 4 коммитов (`git log -1 --format=%B`): `Issue: #700` на каждом; `User-Visible: no` на задачных — корректно, изменение не видно продукту; на докс-коммитах трейлеры избыточны, но не вредят.
8. Сверил, что лейблы `infra`/`process`, использованные в новом шаге создания issue, существуют в репозитории (`gh label list`).
## Гейты: что прогнал, что нет, почему
| Гейт | Статус | Почему |
|---|---|---|
| `npx tsc --noEmit`, `npm test`, `npm run build` + сверка 3 копий бандла | не гонял повторно | зелёный Validate на этом же SHA (run 36492360526) уже подтвердил — цена та же, повторный прогон ничего нового не даст (#343) |
| `node --test test/validate-workflow.test.mjs test/docs-freshness.test.mjs test/classify-changes.test.mjs` | прогнал сам | целевые тесты по диффу; 52/52 |
| 3 мутанта `#700` из `mutation-registry.mjs` вручную | прогнал сам | не требуется на `show`, но дёшево и это единственное прямое доказательство «тест краснеет» на защитных AC |
| `actionlint validate.yml` | не гонял (инструмент не установлен в среде) | синтаксис YAML фактически подтверждён тем, что Validate на этом SHA успешно исполнил job — невалидный YAML GitHub Actions не запустил бы вовсе |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | прогнал | требование прогона по диффу; результат — «нечего выбирать» (нет `src/**`) |
| `npm run golden:verify` | не гонял | нет метки `ci:golden`, golden-файлы не тронуты |
| `python -m pytest tests_backend -q` | не гонял | `custom_components/**/*.py` не тронут |
| `npm run invariants -- --config <export>` | не гонял | геометрия и ссылки на неё не тронуты |
| performance-профили | не гонял | не названы в AC |
| Поведение на push в `main` / тег релиза (не `dev`, не `issue/*`) | проверено чтением, не исполнением | в среде ревью нет доступа поднять реальный push-событие на `main`; `case "$REF" in refs/heads/issue/*)` — единственная новая ветка условия, любой другой `ref` (включая `main`/теги) идёт по прежнему строгому пути `check()`/`--external` без изменений в этой ветке кода |
## AC (тело issue, раздел «Предложение», 3 пункта)
| AC | Чем доказан | Чем краснеет |
|---|---|---|
| 1. На `issue/*` расхождение зеркала и упавшая внешняя ссылка — предупреждение в сводку, без красного job | Автотест `test/validate-workflow.test.mjs` («#700: на ветке задачи…») читает `advise()`/`check()`-ветвление и `--external=warn`; я применил мутант `task-branch-workflow-sync-red-again` (`advise`→`check`) — тест упал; применил `external-link-warn-mode-ignored` — тест упал | Оба мутанта воспроизведены вручную и убиты; откат подтверждён `git status` |
| 2. Блокируют только push в `dev`, кандидат беты, релиз | Прочитано по коду: `case "$REF" in refs/heads/issue/*)` — единственное исключение; `check-docs.mjs` то же самое условие в `validate.yml`. Кандидат беты — коммит с `Release:` на `dev` (тот же `ref`, тот же строгий путь), не отдельная ветка | Проверено чтением, не исполнением (нет доступа поднять push-событие на `main`/тег в среде ревью) |
| 3. Расхождение зеркала на `dev` заводит одно issue, если такого ещё нет (как ночной `#472`) | Шаг «Расхождение зеркала на dev — issue владельцу», `if: push && ref==dev && workflow_sync.outcome=='failure'`; мутант `workflow-sync-issue-duplicated-on-read-failure` (снимает фикс r1: `exit 0`→`:`) убит — тест `#700: на ветке задачи…` красный на мутанте, зелёный на исходнике | Мутант воспроизведён вручную и убит; логика идентична принятому прецеденту `_mutation-gate.yml:317-333` (одно issue, комментарий к открытому) |
Все три AC — track `show`, до трёх штук в теле issue, лимит соблюдён.
## Рассмотрено и отклонено как находка
**Гонка/ложное срабатывание при сбое `git fetch` внутри шага `workflow_sync`.** Шаг начинается с `git fetch --quiet origin main dev`; в GitHub Actions шаги `run:` по умолчанию исполняются с `bash -eo pipefail`, поэтому сбой сети на этой строке уронит шаг **до** цикла `diff`, и `steps.workflow_sync.outcome` станет `failure` без реального расхождения зеркала. После `#700` это не просто красит один прогон (как было раньше) — на push в `dev` это теперь автоматически заводит владельцу постоянный GitHub issue `[workflow-sync]`, который придётся закрывать вручную, хотя расхождения нет.
Не поднимаю как Medium: это тот же дизайн, который уже принят и явно задокументирован в прецеденте, на который ссылается сама задача — `_mutation-gate.yml:275` открытым текстом говорит «любой другой пропуск шардов (упал `material`) по-прежнему заводит issue», то есть владелец уже согласился не различать «содержательный отказ» и «инфраструктурный сбой» ради простоты. Задача `#700` не меняет и не ухудшает это поведение — она копирует уже принятый паттерн один в один. Расширять скоуп до различения причин отказа шага — не работа этой задачи.
**Гонка двух параллельных push в `dev`, оба находят issue не открытым и оба создают дубликат.** Технически возможно между `gh issue list` и `gh issue create` в двух разных прогонах, но `concurrency: group: validate-...${{ github.ref }}` с `cancel-in-progress: true` отменяет предыдущий прогон на том же `ref` при новом push — два прогона `preflight` для `dev` одновременно не живут в штатном случае. Не нахожу воспроизводимого сценария в рамках обычного использования; Low, не блокирует.
## Что проверено и корректно
- `advise()` не устанавливает `fail=1` — предупреждение не красит итоговый вердикт job (проверено чтением и мутантом).
- `check-docs.mjs`: при `--external=warn` отказы внешних ссылок уходят в `warnings`, а не в `errors`, значит `process.exitCode` не выставляется этой причиной — шаг `docs` не падает от чужого сайта на ветке задачи. Остальные проверки документации (пропущенный alt, отсутствующий якорь, скриншот-манифест и т. д.) остаются в `errors` независимо от ветки — AC не про них, и они по-прежнему красят.
- `permissions: issues: write` добавлено на уровне job, а не workflow — оценил риск: `validate.yml` **не входит** в список из шести «тонких» файлов, зеркалируемых в `main` (`process.yml`, `mutation-gate.yml`, `process-resume.yml`, `nightly.yml`, `process-reconcile.yml`, `process-metrics.yml`), значит новое право не нужно синхронизировать в `main` — согласуется с тем, что сам список этот файл не включает.
- Фикс r1 (`gh issue list … || true` → `if ! existing=$(…); then …; exit 0; fi`) закрыт и не регрессировал при ребейзе: воспроизведён мутант, тест падает.
- Трейлеры `Issue:`/`User-Visible:` на месте на обоих коммитах класса B; `User-Visible: no` корректен — изменение невидимо продукту, оба changelog не требуются.
- Одно число — один источник (§8): в диффе нет пользовательски видимых чисел (внутренний CI-процесс), пункт неприменим.
- Лейблы `infra`, `process`, используемые в `gh issue create`, существуют в репозитории.
## Чего не проверял
- `actionlint` сам не гонял (инструмент недоступен в среде ревью) — полагаюсь на успешный прогон Validate на этом SHA как косвенное доказательство валидности YAML.
- Реальное поведение на push в `main` и на релизном теге не воспроизводил исполнением — только чтением кода (нет средства поднять такое событие в среде ревью); риск минимален, так как единственная новая ветка условия — `issue/*`, остальное не тронуто.
- Полные наборы (golden, HA pytest, инварианты, performance) не гонял — ни один AC их не требует, дифф их не касается.
- Мутанты по диффу в `mutation-gate.yml`-смысле («на этом SHA») не запрашивал — не требуется на `track:show`; три мутанта из реестра, которые сам автор завёл под `#700`, проверил вручную вместо этого (не обязательное, но дешёвое усиление).
## Закрытие раунда r3
r3 не вносил новых находок: он применил зелёный вердикт r2 повторно без вызова модели, потому что дерево `a6670a47` было побайтово тем же, что проверялось в r2. Закрывать нечего.
## Унаследовано из r3 / переподтверждено в r4
Код задачи (`validate.yml`, `check-docs.mjs`, три тестовых файла, `mutation-registry.mjs`, `PROCESS.md`) текстуально идентичен тому, что получило зелёный вердикт в `docs/reviews/CODE-REVIEW-700-r2.md` (SHA материала r2 — `ed3d63e2`/`a6670a47`). Несмотря на это, **не переносил вердикт по правилу «дерево не изменилось»**, а провёл полный разбор заново: рабочая копия ребейзнута на 11 коммитов вперёд `dev` (§2.10, «ребейз на ушедший вперёд `dev`» — явное исключение из сокращённого объёма), и сам автор указал, что итоговый контекст `validate.yml` внутри `preflight` сменился из-за параллельных правок `#696`/`#697`. Результат независимого полного разбора совпал с r2: 0 High, 0 Medium.
## Итог
High: 0. Medium (в скоупе): 0. Medium (вне скоупа): 0. Low: 1 (гонка двух параллельных push в `dev`, отклонена как невоспроизводимая при текущем `concurrency`, не правится и не заводится отдельно). Все три AC доказаны — два исполнением с падающим мутантом, один чтением с явной пометкой «проверено чтением, не исполнением» из-за ограничений среды ревью, а не из-за отсутствия автотеста.
**Вердикт: зелёный.**
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/700-preflight-warnings`, коммит `1aa52d210717` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `72952e142f2868e6a1cf473135996b79e5eacb5e`
```
git log --all --format='%H %T' | grep 72952e142f28
```
- Тело issue: `a0c40d40c1401844c3d04ff0695dd347e76172d990004ea2799cc4df38729fce`
- Вердикт конвейера: `green` · High 0
+138
View File
@@ -0,0 +1,138 @@
# CODE-REVIEW-701-r1
- Issue: #701 «Канон: 26 расхождений документов между собой и с кодом, дубли правил, уроки одного случая»
- Трек: `show` · заход r1 · блокирующих циклов израсходовано 0 из 2
- Материал: `git log --oneline origin/dev..HEAD` → один коммит `8dcc1cad4edeed369f751d6b9fe96b798b4061e4`
поверх `origin/dev` @ `224d0106`; `git diff origin/dev...HEAD` — 21 файл,
232 вставки / 111 удалений
- Класс изменений: B (scripts, tests, `.github/workflows/_process.yml`) + C
(docs) — нет файлов класса A; задача инфраструктурная (PROCESS.md §1),
ТЗ не пишет — соответствует правилу
- Validate на этом SHA зелёный: https://github.com/Matysh/houseplan-card/actions/runs/36486646199
## Скоуп
Issue фиксирует 26 расхождений (D1–D26) между `PROCESS.md`, ролевыми
конспектами (`AUTHOR.md`, `REVIEWER.md`), `AGENTS.md`, `TESTING.md`,
`CONTRIBUTING.md`, `docs/DEVELOPMENT.md`, `docs/STATUS.md`, `docs/LESSONS.md`,
`docs/specs/README.md`, промптом `_process.yml` и исполняемым кодом
(`process-gate.mjs`, `validate-commit-provenance.mjs`, `mutation-registry.mjs`,
`entry-cost.mjs`, `gate-small.mjs`). Автор решает каждое расхождение «в пользу
кода, если код — действующее решение владельца, иначе чинится код» и добавляет
одно материальное поведенческое изменение: **документационный коммит (только
файлы класса C) больше не требует трейлеров `Issue:`/`User-Visible:`** — вынесен
общий модуль `scripts/change-classes.mjs`.
Работа обслуживает J6 «Keep the plan true as the home evolves» лишь косвенно —
это сам процесс поддержки продукта в согласованном состоянии, разрешено §1
AGENTS.md как «infrastructure» и подтверждено меткой `process` + `docs`.
## Как проверялось
Прочитан весь `git diff origin/dev...HEAD` (все 21 файл, все 15 hunks в
`PROCESS.md`), сопоставлен построчно с таблицей решений D1–D26 из комментария
автора. Ниже — таблица только по пунктам, где проверка потребовала выйти за
простое чтение диффа (код + тест + мутант); остальные D-пункты — точечные
правки текста, сверенные визуально построчно с диффом и не приведённые
отдельной строкой, чтобы не раздувать документ.
| D | Что проверено | Результат |
|---|---|---|
| D1 | `scripts/change-classes.mjs` — новый общий модуль; `validate-commit-provenance.mjs` импортирует `classify`, добавляет `isDocsOnlyCommit`; `process-gate.mjs` импортирует `classify` вместо локальной копии | код идентичен по регэкспам исходной копии из `process-gate.mjs` (diff — чистый вырез/вставка), порядок D→A→B→C сохранён |
| D1 (тест) | `isDocsOnlyCommit` доказана | `node --test test/commit-provenance.test.mjs` — зелёный; вручную применены оба мутанта реестра (`docs-only-commit-needs-trailers-again`, `docs-only-exemption-leaks-to-code`) — тест краснеет на обоих, рабочая копия восстановлена (`git status --porcelain` пуст) |
| D1 (хук) | `.githooks/commit-msg` зовёт `--staged`, значит `changedFiles` — реальный staged-список, а не пустой список из документного примера | прочитан `.githooks/commit-msg:13-14` |
| D1 (реестр) | `legacy/**` классифицируется как C с комментарием «только Markdown» | `find legacy -type f \| grep -v '\.md$'` → 0 файлов, подтверждено |
| D2 | `REVIEW_DOC_LIMIT = 6` в `process-gate.mjs` не менялся, только текст PROCESS.md подгонян под код | `grep -n REVIEW_DOC_LIMIT scripts/process-gate.mjs` совпадает с новой формулировкой «шесть документов одного вида» |
| D14 | Порядок чтения ревьюера в `_process.yml` (SCOPE→AGENTS→REVIEWER) совпадает с `ROUTES.reviewer.files` в `entry-cost.mjs` | тест `test/entry-cost.test.mjs` «#701 D14» зелёный; `node scripts/entry-cost.mjs` — совпадение подтверждено |
| Слова входа | Автор заявляет 5345→4703 (в тексте issue) / 5171→4703 (комментарий); коммит называет 5345→4703 | `node scripts/entry-cost.mjs` на HEAD даёт 4706 слов (расхождение в 3 слова — рероллы markdown/пробелы, не расхождение по существу) |
| Гейты | Юниты, задетые диффом | `node --test test/commit-provenance.test.mjs test/entry-cost.test.mjs test/process-digests.test.mjs test/pre-push-gate.test.mjs` — все зелёные (19+13+1+12 тестов) |
| Смоки | Есть ли исполняемый frontend-дифф | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` → «Исполняемого frontend-диффа нет … Browser-smoke этим диффом не выбираются — выбирать нечего» — прямой и однозначный ответ инструмента, ни один смок не пропущен |
| Трейлеры | `Issue:`/`User-Visible:` коммита | `git show -s --format=full HEAD` — `Issue: #701`, `User-Visible: no`; коммит сам содержит файлы класса B (scripts/tests/workflow), поэтому по новому правилу трейлеры обязательны и присутствуют — код применён к себе корректно |
## Что не проверял (и почему это ничего не пропускает)
- `npx tsc --noEmit`, `npm test` (полный), `npm run build` + сверка бандла —
не перегонял: Validate на этом же SHA `8dcc1cad` зелёный (ссылка выше),
бюджет раунда потрачен на чтение кода, как разрешает промпт ревью.
- `npm run golden:verify` — не запускался: метки `ci:golden` на issue нет,
дифф не касается `demo/golden/**` и рендера.
- `python -m pytest tests_backend -q` — не запускался: дифф не касается
`custom_components/**/*.py`.
- `npm run invariants -- --config <export>` — не запускался: дифф не меняет
геометрию модели ни ссылки на неё.
- Performance-профили — не запускались: не названы в AC/issue.
- Полный `npm run inventory` / полный прогон `mutation-gate.yml` (все шесты
шардов) — не запускался; автор заявляет локальный прогон диф-мутантов
«184 из 184» на `show`, где диф-мутанты не обязательны (REVIEWER.md, «Трек
show»): проверены точечно только два новых мутанта, относящихся к диффу
(см. таблицу выше), остальные 182 не переисполнялись — трек `show` этого не
требует.
- Не перечитывал все 26 точечных текстовых правок построчно вслух в этом
документе — свёл к таблице выше плюс визуальному сопоставлению с полным
диффом (`git diff origin/dev...HEAD -- PROCESS.md` и остальные файлы,
прочитаны целиком инструментом чтения, не по фрагментам).
## Находки
Нет находок уровня High или Medium. Ниже — один пункт уровня Low, снятый без
правки.
- **Low, снят.** Число слов входа автора в коммите/issue (5345/5171→4703) и на
измеренном HEAD (4706) расходятся на 2–3 слова. Причина, скорее всего, —
разница момента замера (до/после финальной правки одного предложения) или
особенность подсчёта `wc -w`-подобной функции на переносах строк; описанный
порядок величины (экономия ~640–700 слов, цель 3000 не достигнута, остаток
назван) не меняется, инструмент (`node scripts/entry-cost.mjs`) сам
показывает актуальное число при каждом запуске, а не текст документа —
учебная находка, не поведенческая. Снимаю без возврата автору.
## Что проверено и корректно
- Общий модуль классов (`change-classes.mjs`) устраняет дублирование логики
между `process-gate.mjs` и `validate-commit-provenance.mjs`, поведение не
изменилось для не-docs-only коммитов (тесты на классах A/B/D в
`test/commit-provenance.test.mjs` не тронуты и зелёные).
- Новое освобождение docs-only коммитов от трейлеров реализовано корректно:
пустой список файлов не считается docs-only («судить нечем»), любой файл вне
класса C возвращает коммит под старое правило, указанный трейлер всё равно
проверяется на валидность (тест `docs: typo\n\nIssue: #x` всё ещё краснеет).
Оба защитных инварианта доказаны мутантами и я лично воспроизвёл оба падения.
- Изменение маршрута автора (`docs/STATUS.md` убран из обязательного входа)
синхронно отражено в `entry-cost.mjs`, `AGENTS.md` и тексте PROCESS.md —
один источник, три места ссылаются, а не повторяют число.
- Переупорядочение маршрута ревьюера в промпте `_process.yml` (SCOPE→AGENTS→
REVIEWER) сопровождается новым тестом, который сверяет промпт и
`entry-cost.mjs` исполнением, а не на слово доверия.
- Автор честно перечислил недоделанное (п.4 «уроки → чек-листы подсистем» и
остаток до 3000 слов) как отдельное issue-предложение, а не спрятал в тексте.
- Одно число, видимое дважды в этом диффе (порог `REVIEW_DOC_LIMIT`), имеет
один источник — константу в `process-gate.mjs`; текст PROCESS.md её называет,
не дублирует.
- Риск, названный самим автором (файл, ошибочно отнесённый к классу C,
пройдёт без трейлеров), — не находка ревью: он не увеличился этим диффом
(тот же риск существовал и раньше в неявном виде, для `process-gate.mjs`),
а сведение классов в один модуль его уменьшает, а не создаёт.
## Раздел «Унаследовано» — не применяется
Это первый заход (r1) по этому issue; предыдущего раунда и материала нет.
## Вердикт
Зелёный. AC (D1–D26 плюс материальное изменение трейлеров) доказаны кодом,
тестами и исполнением мутантов там, где они заявлены; гейты, применимые к
этому диффу, зелёные или обоснованно не запускались; High/Medium находок нет.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/701-canon-consistency`, коммит `8dcc1cad4ede` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `18f1bb93547b43c41d19aecdcecb67e0fe35f3c2`
```
git log --all --format='%H %T' | grep 18f1bb93547b
```
- Тело issue: `592acae7c08ffffa64b312987ca7cff1e28155412a2c6531cb3e4eab0877eedf`
- Вердикт конвейера: `green` · High 0
+197
View File
@@ -0,0 +1,197 @@
# CODE-REVIEW-702-r1
Issue: #702 · Трек: show · Заход: r1 · Блокирующих циклов израсходовано: 0 из 2
Материал ревью: `3a681e24e49038f202fc74cc3f5482e93e43977e` (ветка `issue/702-delete-merged-branches`, один коммит поверх `dev@c716bb0f`)
## Скоуп
Задача — гигиена веток конвейера (issue #702): 368 влитых `issue/*` веток
висели на `origin`, список веток переставал что-то значить, агент, искавший
ветку по номеру, мог взять устаревшую. Предложение из тела issue:
1. После успешного слияния `merge-candidate.mjs` удаляет ветку задачи;
удаление — только если вершина ветки равна влитому кандидату.
2. Разовая чистка уже влитых веток — список владельцу, удаление только после
явного согласия.
3. Не трогать `codex/*`, релизные и невлитые ветки.
Диапазон правки (`git diff origin/dev...HEAD`):
| Файл | Класс | Что |
|---|---|---|
| `scripts/merge-candidate.mjs` | B (tooling) | `deleteBranch()` в `realOps`; вызов из `finish()` с `branchTip`, проброшенным из обеих веток слияния (fast-forward и push после Validate); текст комментария в `commentFor()` |
| `PROCESS.md` §10.4 | C (docs) | пункт в списке шагов слияния |
| `test/merge-candidate.test.mjs` | B | 4 новых теста (`fakeOps` получил `deleteBranch`/`deleteOk`) |
| `scripts/mutation-registry.mjs` | B | мутант `merged-task-branch-kept` |
Файлов класса A нет — продуктовый код карточки не тронут, отсюда
`User-Visible: no` в трейлере коммита (корректно). Пункт 2 (разовая чистка)
в диффе не реализован кодом — по комментарию автора список подготовлен
(`branches-to-delete-702.txt`), удаление не выполнялось, ждёт согласия
владельца. Это соответствует AC: чистка — ручное действие владельца, не
автоматизация этой задачи.
Ветка к `dev` не приводилась (трек show, #696): сливается с открытыми
задачами #697–#700 без конфликта (`git merge-tree`, со слов автора; не
перепроверялось — не относится к AC этой задачи).
## Как проверялось
Ревью читало код и намеренно воспроизвело граничный случай, который юнит-тест
подменяет фейком: сам механизм `git push --force-with-lease` на удаление ветки.
- Прочитан `scripts/merge-candidate.mjs` целиком: путь `finish()`, оба места,
где `branchTip` попадает в `extra` (строка ~287 — fast-forward, ~332 —
после зелёного Validate), и путь `commentFor()`.
- Прочитан весь `test/merge-candidate.test.mjs`: `fakeOps`, все четыре новых
теста и их согласованность с `mergeCandidate()`.
- Выполнен независимый эксперимент на временном bare-репозитории (два
прогона): `git push --force-with-lease=refs/heads/X:<ожидание> origin
:refs/heads/X` — (а) при совпадении ожидания с реальной вершиной ветка
удаляется (`exit 0`); (б) при устаревшем ожидании push отклоняется с
`! [rejected] (delete) -> feature (stale info)`, ветка остаётся. Это
подтверждает, что регэксп `/stale info|rejected|fetch first|lease/i` в
`deleteBranch()` ловит именно ту ошибку, которую реально отдаёт git, а не
придуманную автором строку.
- Прогнаны узкие автотесты и гейты, относящиеся к диффу (список — в таблице
«Гейты» ниже).
- Проверены трейлеры коммита, сверка чисел (нет числа, видимого дважды —
единственное число диффа, SHA/статусы, генерируется кодом, не
дублируется руками).
- Проверено, что job `integrate` в `_process.yml`, вызывающая
`merge-candidate.mjs`, использует не GITHUB_TOKEN (у которого в этом job
только `contents: read`), а секрет `HP_PROCESS_TOKEN` через явный URL с
токеном — то есть право на push/delete не зависит от `permissions:` блока
job и не сужено этой задачей.
- Проверено, что ни один другой скрипт (`task-packet.mjs`,
`process-gate.mjs`, `process-metrics.mjs`, `wait-verdict.mjs`) не
предполагает существование ветки задачи после `S8-merged` — удаление
ничего не ломает по цепочке.
## AC · чем доказан · чем краснеет
| AC | Чем доказан | Чем краснеет |
|---|---|---|
| Ветка удаляется после успешного слияния (fast-forward и push) | `test/merge-candidate.test.mjs` (2 теста), исполнено (26/26) | Мутант `merged-task-branch-kept` (`if (false && merged && …)`) — гард `node --test --test-name-pattern="#702" test/merge-candidate.test.mjs`; прогнан лично: `node scripts/mutation-gate.mjs --id=merged-task-branch-kept` → «merged-task-branch-kept: заявленный тест покраснел на мутанте», «поймано 1 из 1» |
| Удаление — только если вершина ветки на origin равна вершине, которую видело слияние (lease) | Юнит-тест «сдвинутая вершина — ветка остаётся» (`deleteOk: false`); плюс независимый ручной эксперимент на настоящем git (см. «Как проверялось») — подтверждает, что `--force-with-lease` на удаление действительно защищает от гонки, а не только в фейке | Ручной эксперимент — стерев `--force-with-lease` до простого `git push origin :refs/heads/X`, оба ручных прогона поменяли бы поведение (случай (б) удалил бы ветку вместо отказа); отдельного мутанта на этот случай нет, но реальный git-эксперимент закрывает то же самое доказательство подлинным исполнением, а не фейком |
| Красный/незавершённый merge не удаляет ветку (red Validate, conflict, reject-stale, rereview, give-up) | Юнит-тест: красный Validate → `calls.filter(delete) == []`; устаревшая ветка (#312) → `calls.filter(delete) == []` | Проверено чтением: во всех этих исходах `finish()` вызывается без `branchTip` в `extra`, а `merged` вычисляется из `decision.action`, который в этих ветках никогда не `push`/`fast-forward` — структурно недостижимо, что делает отдельный мутант избыточным для этой части |
| Разовая чистка 368 влитых веток — не автоматически, только после согласия владельца | Чтение диффа: код чистки в этом коммите отсутствует; issue-комментарий автора подтверждает, что ничего не удалено | Не мутант — это отсутствие функциональности, доказывается отсутствием кода, а не тестом |
## Находки
Нет High. Нет Medium в скоупе. Нет Medium вне скоупа.
**Low (снята с записью, не блокирует):** если `deleteBranch()` бросает
исключение, НЕ совпадающее с lease-регэкспом (например, реальная ошибка прав
или блокировка ветки правилом защиты) — оно ловится в `finish()`
(`catch (error) { ops.log(...) }`), и `branchDeleted` остаётся `null`.
Комментарий в issue в этом случае вообще не упоминает попытку удаления —
ни «удалена», ни «оставлена: вершина сдвинулась». Различить в
issue-комментарии «ветка осталась, потому что автор допушил коммит»
(ожидаемо, задокументировано) от «ветка осталась из-за сбоя git/прав»
(неожиданно) можно только по логу job в Actions, который не хранится
вечно и не виден из issue. Не блокирует: инвариант «сбой удаления не
отменяет слияние» соблюдён, риска для данных нет — в худшем случае
лишняя ветка молча остаётся висеть, то есть today's статус-кво. Снимаю
без правки: цена читаемости лога ниже цены нового условного пути в
и так плотной функции `finish()`.
## Что проверено и корректно
- `deleteBranch(ref, expected)` в `realOps` использует тот же
`pushUrl` (с токеном), что и `pushWithLease`, и тот же паттерн
`--force-with-lease=refs/heads/${ref}:${expected}` — синтаксис реально
поддерживается git для удаления (подтверждено экспериментом), не
придуман.
- Оба места вызова `finish(..., { branchTip })` передают корректную
«последнюю вершину, которую видело слияние»: `tip` (= `actual`) в
fast-forward-пути, где ветка задачи вообще не пушится, и `candidate`
(уже реально запушенный в `branch` строкой раньше, `pushWithLease(candidate,
branch, tip)`) в пути с ребейзом — совпадает с ожиданием юнит-тестов
(`cand-mat-on-dev1` и т. п.).
- Все исходы, где `merged !== true` (`conflict`, `rereview`,
`validation-red`, `validation-missing`, `give-up`, `reject-stale`),
структурно не передают `branchTip` и не вызывают удаление — проверено и
чтением, и тестами.
- `commentFor()` добавляет к тексту «слито» одну из двух опциональных
фраз строго по `ctx.branchDeleted === true/false`; при `null` — исходный
текст без изменений, обратной совместимости с существующими тестами
комментариев не сломано (`assert.match(commentFor('push', ctx), /… слито$/)`
по-прежнему проходит, т.к. `ctx.branchDeleted` не задан → `undefined`).
- Мутант `merged-task-branch-kept` синтаксически корректен (`find` совпадает
один-в-один со строкой файла), гард называет реальный тест, реестр цел
(`node --test test/mutation-gate.test.mjs` — 69/69, структурные проверки
реестра проходят).
- Трейлеры коммита: `Issue: #702`, `User-Visible: no` — верно, продуктовый
код (класс A) не менялся.
- PROCESS.md §10.4 обновлён текстом, согласованным с кодом (обе ветки:
«после push» покрывает и fast-forward, и обычный push).
- Ни один другой скрипт конвейера не читает ветку задачи после
`S8-merged` — удаление ничего дальше по цепочке не ломает.
- Число, которое дифф делает видимым (368/370 влитых веток), — не
дублируется в коде: это только текст комментария issue автора, не число
в PROCESS.md или тесте; в самом диффе такого числа нет вовсе — нечему
расходиться.
## Чего не проверял
- `npx tsc --noEmit`, `npm test` (полный), `npm run build` +
сверка трёх копий бандла — не перегонял: зелёный Validate на этом же SHA
(`3a681e24`) уже подтверждён:
https://github.com/Matysh/houseplan-card/actions/runs/36482861441
- Браузерные смоки — не прогонял: `node scripts/smoke-select.mjs --base
origin/dev --head HEAD` сам ответил «исполняемого frontend-диффа нет
(`src/**/*.ts` не тронут) — смоки этим диффом не выбираются, выбирать
нечего». Явных смоков в AC issue не названо.
- `npm run golden:verify` — не прогонял, нет изменений рендера/UI.
- `python -m pytest tests_backend -q` — не прогонял, нет правок
`custom_components/**/*.py`.
- `npm run invariants` — не прогонял, диффа геометрии/модели нет.
- Performance-профили — не названы в AC, не прогонял.
- Реальное поведение `merge-candidate.mjs` целиком на живом GitHub Actions
(реальный `gh`/`GH_TOKEN`, реальные права `HP_PROCESS_TOKEN` на удаление
ветки в этом конкретном репозитории) — не проверял; это уже
ответственность реального прогона `integrate` при следующем слиянии, не
предмет ревью кода. Логика вокруг него (аргументы, условия, регэксп
ошибки) проверена чтением и одним независимым git-экспериментом на
временном репозитории (см. «Как проверялось»).
- Разовая чистка 368 веток (пункт 2 issue) — не в этом коммите, поэтому не
предмет код-ревью; когда владелец даст согласие и появится код/действие,
это отдельный материал.
## Гейты — сводка
| Гейт | Прогнан | Результат |
|---|---|---|
| typecheck / npm test / build+bundle-policy | нет — Validate на SHA материала | зелёный: run 36482861441 |
| `node --test test/merge-candidate.test.mjs` | да | 26/26 |
| `node --test test/process-digests.test.mjs` | да | 5/5 |
| `node --test test/mutation-gate.test.mjs` | да (целостность реестра) | 69/69 |
| `node scripts/mutation-gate.mjs --id=merged-task-branch-kept` | да | мутант убит, 1/1 |
| `node scripts/process-gate.mjs --range origin/dev..HEAD` | да | 0 предупреждений |
| `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | да | нет frontend-диффа, смоки не выбираются |
| `golden:verify` | нет | не применимо (нет диффа рендера) |
| `pytest tests_backend` | нет | не применимо (нет правок Python) |
| `invariants` | нет | не применимо (нет правок геометрии) |
| ручной git-эксперимент force-with-lease на удаление ref | да | подтверждает регэксп ошибки в `deleteBranch()` соответствует реальному git |
## Вердикт
Зелёный. AC выполнены и доказаны (автотестом + мутантом + независимым
ручным экспериментом с реальным git на защитную часть); находок,
блокирующих или требующих правки в скоупе, нет; единственная Low-находка
снята с записью.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/702-delete-merged-branches`, коммит `3a681e24e490` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `387871be0d5bb95001506344a933150fb5ca5d16`
```
git log --all --format='%H %T' | grep 387871be0d5b
```
- Тело issue: `34ce91f776aff8956b77aea955e69a919ee4f52be89c8e94beb90f1e9beb5e85`
- Вердикт конвейера: `green` · High 0
+138
View File
@@ -0,0 +1,138 @@
# Код-ревью #706 — заход r1
Материал ревью: `origin/dev...HEAD`, вершина `ae0e516af16e2ebcea9642949135cb950ce65eb3`
(один коммит поверх `dev@e7fca7b9`). Трек `show`, мутанты по диффу на материале не
запрашивались (не находка).
## Скоуп
Конвейер перестаёт снимать `S7-code-review` без возврата, когда исход `rereview`
после точного кандидата (#492) возвращает задачу в ту же метку, из которой она
пришла: `FROM == TO`. Совмещённый вызов `gh issue edit --add-label X --remove-label X`
добавлял и тут же снимал одну метку — итог был «без статуса», событие `labeled` не
приходило, новый заход ревью не стартовал (см. инцидент #699, зафиксированный в теле
issue).
Правка вводит `scripts/status-label.mjs` (`moveStatusLabel`): при `FROM == TO` метка
снимается и ставится заново двумя вызовами `gh` через `relabel` автосверки (#555,
`scripts/process-reconcile.mjs:342`) — одна попытка восстановления, затем исключение;
при разных метках — как раньше, одной правкой. Шаг «Переставить метку» в
`_process.yml` вызывает скрипт вместо инлайн `gh issue edit`. `PROCESS.md` описывает
новое поведение точного кандидата. Три AC из тела issue закрыты тестами
`test/status-label.test.mjs` и двумя мутантами в `scripts/mutation-registry.mjs`.
Класса A (публичный API/контракт) в диффе нет — только скрипты процесса и workflow.
## Как проверялось
Дешёвые гейты на этом SHA уже подтверждены (#343): Validate `ae0e516a` — success
(https://github.com/Matysh/houseplan-card/actions/runs/36521621591). `tsc --noEmit`,
`npm test`, `npm run build` на этом прогоне не перегонял; вместо этого:
- прочитан код `scripts/status-label.mjs`, `.github/workflows/_process.yml` (весь шаг
«Переставить метку» и его окружение), `PROCESS.md` (правило точного кандидата),
`scripts/process-reconcile.mjs:342-351` (`relabel`, на которую опирается новый скрипт);
- `node --test --test-name-pattern="#706" test/status-label.test.mjs` — 4/4 зелёных;
- оба новых мутанта применены руками к копии дерева (`/tmp`, не в рабочей копии) и
проверено, что целевой юнит-тест красится:
- `rereview-relabel-in-one-call` (патч `scripts/status-label.mjs`, `if (from === to)`
→ `if (false)`) — тест `#706 rereview` падает: `expected: 'relabeled', actual: 'moved'`;
- `process-label-step-combined-again` (откат шага на инлайн `gh issue edit`) — тест
`#706 шаг конвейера` падает на `assert.doesNotMatch(.../gh issue edit/)`;
- `node scripts/smoke-select.mjs --base origin/dev --head HEAD` —
«Исполняемого frontend-диффа нет (`src/**/*.ts` не тронут)», браузерные смоки не
выбираются — верно: правка не трогает `src/**`;
- `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/_process.yml'))"` —
YAML синтаксически валиден после правки шага;
- `node --check scripts/status-label.mjs` — синтаксис ОК;
- прослежена совместимость сигнатур: локальный `gh(args, {allowFailure})` в
`status-label.mjs` вызывается `relabel(repo, {number: issue}, to, execute)` из
`process-reconcile.mjs` теми же двумя аргументами (`execute(args)` и
`execute(args, {allowFailure: true})`) — контракт совпадает;
- проверено, что заявленный `cancel-in-progress: false` в группе
`concurrency: process-issue-${{ github.event.issue.number }}` действительно стоит на
всех четырёх job (`_process.yml:53-55, 317-319, 974-976, 1350-1352`) — довод автора
«повторный `labeled` не отменяет текущий прогон, а встаёт в очередь» подтверждён;
без этого перестановка метки из середины ещё выполняющегося workflow-прогона могла
бы породить гонку;
- проверено, что комбинация «одна и та же метка на обоих концах» больше нигде в
`_process.yml` не встречается кроме исправленного места (`grep` по
`add-label.*remove-label`): строки 462, 702, 822 — везде разные метки на входе и
выходе, тот же класс бага там не воспроизводится, latent-дефект не пропущен;
- запущен полный `npm run gate:small` в фоне как избыточная перепроверка (дублирует
уже зелёный Validate) — не блокировал вывод вердикта; на момент публикации документа
ещё выполнялся, к находкам не привёл ни на одном пройденном шаге.
## AC · чем доказан · чем краснеет
| AC | Доказательство | Чем краснеет | Проверено |
|---|---|---|---|
| AC1: `FROM == TO` → снятие, затем постановка, задача остаётся в `S7-code-review` | `#706 rereview` | `rereview-relabel-in-one-call` | тест зелёный; мутант применён вручную — тест краснеет (см. выше) |
| AC2: `FROM ≠ TO` → одна правка; без `FROM` — только постановка | `#706 обычный исход` | — (поведение до #706, регрессии не требуется) | тест зелёный, прочитан код ветки `moved` |
| AC3: сбой повторной постановки роняет шаг; в шаге нет совмещённого `gh issue edit` | `#706 сбой…`, `#706 шаг конвейера` | `process-label-step-combined-again` (для второй половины); восстановление — мутанты `relabel` из #555 | тесты зелёные; мутант применён вручную — тест краснеет (см. выше) |
Все три AC — защитные (гард против «задача осталась без статуса молча»), у каждого
есть строка «чем доказан/чем краснеет» с результатом прогона, пустых столбцов нет.
## Находки
Не найдено. Синтаксис аргументов CLI (`arg()` в `status-label.mjs`) проверен вручную
на всех четырёх флагах (`--repo=`, `--issue=`, `--from=`, `--to=`) — смещение среза
`name.length + 3` совпадает с длиной префикса `--{name}=` для каждого; ошибки на
пустом `--from=` (случай без исходной метки) не возникает, т.к. пустая строка — валидный
случай по AC2.
## Что проверено и корректно
- `moveStatusLabel` в обычном случае (`FROM ≠ TO`) даёт байт-в-байт то же поведение,
что и прежний инлайн `gh issue edit --add-label TO --remove-label FROM` — регрессии
для всех исходов, кроме `rereview`, нет.
- Обработка ошибок сохраняет прежнюю семантику шага: неудача любого `gh`-вызова
бросает исключение → `console.error('::error::...')` → `process.exit(1)` →
шаг падает → срабатывает последующий `if: failure()` («Позвать владельца»), как и
раньше при падении инлайн-команды.
- `PROCESS.md` (§ про точного кандидата) обновлён в том же диффе и согласован с кодом:
описывает именно снятие-и-постановку при разошедшемся patch-id, а не общий случай.
- User-Visible-трейлер не требуется и не заявлен — правка невидима пользователю
карточки (внутренний процесс ревью), changelog не тронут — это ожидаемо.
- Тесты корректно чувствительны: `#706 шаг конвейера` проверяет и наличие вызова
скрипта, и отсутствие возврата к `gh issue edit` — не только позитивную, но и
негативную сторону структуры шага.
## Чего не проверял
- Живой прогон `rereview` на самом конвейере — по признанию автора, воспроизводится
только при следующем слиянии со сменой patch-id; не гейт этого ревью (сценарий по
своей природе недоступен до реального конкурентного слияния).
- `npm run gate:small`, `process-gate --range`, `mutation-gate --check`,
диф-мутанты по 6 шардам (162/162) — заявлены автором, не перегонял; `tsc --noEmit`,
`npm test`, `npm run build` покрыты цитированным зелёным Validate на этом SHA и
повторно не гонялись согласно правилу дешёвых гейтов; `npm run gate:small` запущен
фоново как необязательная подстраховка, не как обязательный гейт этого раунда.
- `actionlint` — бинарь недоступен в этом окружении; синтаксис шага проверен вручную
построчным сравнением с `find`/`replace` мутанта и YAML-парсером Python, но
actionlint-специфичные проверки (например, контекст `steps.*.outputs` в других
частях файла) не перегонялись.
- Golden, pytest, инварианты модели — диф не трогает `src/**`, рендер, геометрию или
Python-бэкенд; по правилам объёма гейтов не применимы, что подтверждено
`smoke-select.mjs`, а не только предположением.
## Вердикт
Зелёный. Все три AC доказаны тестами, которые я проверил на способность падать
(применил оба мутанта вручную к копии дерева). Класса A нет, User-Visible не требуется,
изменение локально и соразмерно заявленной сложности 2/10.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `issue/706-rereview-label`, коммит `ae0e516af16e` — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
- Дерево материала: `2441daa60197945e66175706f34258bb589764c7`
```
git log --all --format='%H %T' | grep 2441daa60197
```
- Тело issue: `c4af86c1bc1383ec564be59c70334fb4158021c86e84c0d13f23951152fc619d`
- Вердикт конвейера: `green` · High 0
+14 -1
View File
@@ -1,9 +1,21 @@
# Индекс ревью
Генерируется `node scripts/reviews-index.mjs` (#635) — не редактировать руками. Документов: 176, issue: 82. Вердикт: 🟢 зелёный · 🟡 жёлтый · 🔴 красный · ⚪ не распознан (свободная форма старых документов). H/M — число High/Medium по строке вердикта или заголовкам находок. Файлы — пути, названные в находках; ищите по имени файла: `grep form-kit INDEX.md`.
Генерируется `node scripts/reviews-index.mjs` (#635) — не редактировать руками. Документов: 189, issue: 90. Вердикт: 🟢 зелёный · 🟡 жёлтый · 🔴 красный · ⚪ не распознан (свободная форма старых документов). H/M — число High/Medium по строке вердикта или заголовкам находок. Файлы — пути, названные в находках; ищите по имени файла: `grep form-kit INDEX.md`.
| Issue | Документ | Этап · раунд | Вердикт | H | M | Находки | Файлы |
|---|---|---|---|---:|---:|---|---|
| #706 | [CODE-REVIEW-706-r1.md](CODE-REVIEW-706-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — |
| #702 | [CODE-REVIEW-702-r1.md](CODE-REVIEW-702-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — |
| #701 | [CODE-REVIEW-701-r1.md](CODE-REVIEW-701-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — |
| #700 | [CODE-REVIEW-700-r1.md](CODE-REVIEW-700-r1.md) | code · r1 | 🟡 жёлтый | 0 | 1 | gh issue list fail-open вместо fail-safe в новом шаге создания issue | `.github/workflows/validate.yml` `.github/workflows/_mutation-gate.yml` |
| #700 | [CODE-REVIEW-700-r2.md](CODE-REVIEW-700-r2.md) | code · r2 | 🟢 зелёный | 0 | 0 | — | — |
| #700 | [CODE-REVIEW-700-r4.md](CODE-REVIEW-700-r4.md) | code · r4 | 🟢 зелёный | 0 | 0 | — | — |
| #699 | [CODE-REVIEW-699-r1.md](CODE-REVIEW-699-r1.md) | code · r1 | 🟡 жёлтый | 0 | 1 | Новый шаг «опустить потолки на бете» не попал в единственный канонический release-runbo…; Оркестрация tighten (ветка command === 'tighten' в scripts/ratchets.mjs) не покрыта авт… | `docs/DEVELOPMENT.md` `RELEASE-NOTES.md` `PROCESS.md` `scripts/ratchets.mjs` `release-prerelease.mjs` |
| #699 | [CODE-REVIEW-699-r2.md](CODE-REVIEW-699-r2.md) | code · r2 | 🟢 зелёный | 0 | 0 | — | — |
| #699 | [CODE-REVIEW-699-r3.md](CODE-REVIEW-699-r3.md) | code · r3 | 🟢 зелёный | 0 | 0 | — | — |
| #698 | [CODE-REVIEW-698-r1.md](CODE-REVIEW-698-r1.md) | code · r1 | 🟡 жёлтый | 0 | 1 | PROCESS.md утверждает существование «теста полосы на Validate» для монолитного бейзлайн…; scripts/rebase-on-dev.mjs: предиктивный --dry-run-лог (splitConflicts/predicted.manual)… | `PROCESS.md` `scripts/monolith-baseline.json` `scripts/rebase-on-dev.mjs` `docs/CHANGELOG.md` `docs/CHANGELOG.ru.md` |
| #698 | [CODE-REVIEW-698-r2.md](CODE-REVIEW-698-r2.md) | code · r2 | 🟢 зелёный | 0 | 0 | — | — |
| #697 | [CODE-REVIEW-697-r1.md](CODE-REVIEW-697-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — |
| #696 | [CODE-REVIEW-696-r1.md](CODE-REVIEW-696-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — |
| #695 | [CODE-REVIEW-695-r1.md](CODE-REVIEW-695-r1.md) | code · r1 | 🟡 жёлтый | 0 | 1 | «инфраструктура без метки трека» не читается как track:show нигде в коде | `PROCESS.md` `.github/workflows/_process.yml` `scripts/task-packet.mjs` `_process.yml` `task-packet.mjs` `docs/process/AUTHOR.md` `test/task-packet.test.mjs` |
| #695 | [CODE-REVIEW-695-r2.md](CODE-REVIEW-695-r2.md) | code · r2 | 🟢 зелёный | 0 | 0 | — | — |
@@ -11,6 +23,7 @@
| #691 | [SPEC-REVIEW-691-r2.md](SPEC-REVIEW-691-r2.md) | spec · r2 | 🟢 зелёный | 0 | 0 | — | — |
| #691 | [CODE-REVIEW-691-r1.md](CODE-REVIEW-691-r1.md) | code · r1 | 🟡 жёлтый | 0 | 2 | docs/USER-GUIDE.ru.md не обновлён и описывает старое поведение; существующий регрессионный смок demo/smoke_pan_any_zoom.mjs красный на материале ревью | `docs/USER-GUIDE.ru.md` `AGENTS.md` `docs/process/REVIEWER.md` `docs/SCOPE.md` `docs/USER-GUIDE.md` `docs/TOUCH-SUPPORT.md` `demo/smoke_pan_any_zoom.mjs` `docs/CANVAS.md` |
| #691 | [CODE-REVIEW-691-r2.md](CODE-REVIEW-691-r2.md) | code · r2 | 🟢 зелёный | 0 | 0 | — | — |
| #690 | [CODE-REVIEW-690-r1.md](CODE-REVIEW-690-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — |
| #689 | [SPEC-REVIEW-689-r1.md](SPEC-REVIEW-689-r1.md) | spec · r1 | 🟢 зелёный | 0 | 0 | — | — |
| #689 | [CODE-REVIEW-689-r1.md](CODE-REVIEW-689-r1.md) | code · r1 | 🟢 зелёный | 0 | 0 | — | — |
| #688 | [SPEC-REVIEW-688-r1.md](SPEC-REVIEW-688-r1.md) | spec · r1 | 🟢 зелёный | 0 | 0 | — | — |
+4 -9
View File
@@ -8,13 +8,8 @@
Здесь остались только ТЗ, на которые ссылаются живые код, тесты и документы (ADR, `ISOMETRIC.md`, `SUN.md`, `RADAR.md`, `LIGHT.md`, `DECOR-EDITOR.md`, support-relay), и те, на которые ссылаются они сами. Остальные ТЗ выпущенных задач перенесены в [`legacy/specs/`](../../legacy/specs/) (#682): историю не переписываем, ссылки из документов ревью ведут по SHA и живут дальше. Каталог нужен `scripts/task-packet.mjs` и проверке 3 `scripts/process-gate.mjs`.
## Обязательные release-артефакты ТЗ
## Правила ТЗ
Если задача меняет пользовательское поведение, её ТЗ обязано явно перечислить:
- записи в `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md`;
- затронутую пользовательскую документацию;
- требуемые screenshots/golden и способ их review, если меняется визуал;
- release/performance/security artifacts, если они входят в acceptance gate.
Отсутствие этого раздела не означает, что документация необязательна. Для чистого refactoring ТЗ должно прямо зафиксировать отсутствие пользовательских изменений и перечислить технические доказательства безопасного поведения.
Здесь не живут (#701): архив правил не задаёт. Обязательные разделы ТЗ, включая
release-артефакты (changelog RU+EN, документация, скриншоты и golden, perf и
security, если они входят в приёмку), — `PROCESS.md` §7.1 и DoR `S5-ready` (§2).
+25 -28
View File
@@ -389,6 +389,14 @@ export const LOW_HEADROOM_WARNING_BYTES = 15_000;
* границы полосы; общий бюджет 301 066 Б не меняется.
*/
export const INITIAL_VIEW_GZIP_CEILING = 301_000;
/**
* #699 (решение владельца 2026-09-28): полоса — над потолком, а не под ним.
* Задача может вырасти не больше чем на полосу над потолком беты, падение её не
* красит; «перецентрирование» на задаче ушло — потолок ставит до факта бета
* (`node scripts/ratchets.mjs tighten`). Прежде правило было двусторонним,
* `[ceiling - band, ceiling]`, и задача у верхней границы поднимала потолок
* сама — как #691. Абсолютный бюджет `INITIAL_VIEW_GZIP_BUDGET` остаётся стеной.
*/
export const INITIAL_VIEW_CEILING_BAND = 2_000;
/**
@@ -535,24 +543,16 @@ export function initialViewCeilingViolation(bytes, {
if (!Number.isFinite(bytes)) {
return { kind: 'missing', text: 'initial View graph не измерен — потолок проверить нечем' };
}
if (bytes > ceiling) {
if (bytes > ceiling + band) {
return {
kind: 'grew',
over: bytes - ceiling,
text: `initial View graph ${bytes} B gzip выше потолка ${ceiling} B на ${bytes - ceiling} B.`
text: `initial View graph ${bytes} B gzip выше потолка беты ${ceiling} B на ${bytes - ceiling} B`
+ ` — больше полосы ${band} B.`
+ ' Поднимите потолок в этом же коммите, объяснив рост, либо вынесите код в ленивый'
+ ' граф (история: #367 → #474). Молча расти этому графу больше нечем.',
};
}
if (bytes < ceiling - band) {
return {
kind: 'shrank',
under: ceiling - bytes,
text: `initial View graph ${bytes} B gzip ниже потолка ${ceiling} B на ${ceiling - bytes} B`
+ ` — больше полосы ${band} B. Опустите потолок: незафиксированный выигрыш граф`
+ ' отыграет обратно, и это уже происходило (#367, закрыт).',
};
}
return null;
}
@@ -676,24 +676,17 @@ export function lazyGraphCeilingViolation(bytes, { ceiling, label, band = LAZY_G
if (!Number.isFinite(bytes)) {
return { kind: 'missing', text: `${label} не измерен — потолок проверить нечем` };
}
if (bytes > ceiling) {
// #699: та же полоса над потолком беты, что у стартового графа.
if (bytes > ceiling + band) {
return {
kind: 'grew',
over: bytes - ceiling,
text: `${label} ${bytes} B gzip выше потолка ${ceiling} B на ${bytes - ceiling} B.`
text: `${label} ${bytes} B gzip выше потолка беты ${ceiling} B на ${bytes - ceiling} B`
+ ` — больше полосы ${band} B.`
+ ' Ленивый граф грузится не в первом кадре, но он всё равно чей-то трафик:'
+ ' поднимите потолок в этом же коммите, объяснив рост, либо уменьшите содержимое.',
};
}
if (bytes < ceiling - band) {
return {
kind: 'shrank',
under: ceiling - bytes,
text: `${label} ${bytes} B gzip ниже потолка ${ceiling} B на ${ceiling - bytes} B`
+ ` — больше полосы ${band} B. Опустите потолок: незафиксированный выигрыш`
+ ' граф отыграет обратно.',
};
}
return null;
}
@@ -819,27 +812,31 @@ export function assertBundleBudget(
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
try {
const manifest = JSON.parse(readFileSync(resolve('dist/houseplan-assets.json'), 'utf8'));
// #699: потолок беты с полосой судится до абсолютного бюджета. С полосой
// над потолком граница `ceiling + band` может лежать выше бюджета, и тогда
// рост краснел бы только бюджетом — а отказ обязан называть храповик,
// который его поймал, и проверка потолка — оставаться исполняемой.
const ceiling = initialViewCeilingViolation(manifest.initialViewGzipBytes);
if (ceiling) throw new Error(ceiling.text);
const result = assertBundleBudget(manifest);
assertSupportBundleOwnership(manifest);
assertNamespaceLocaleOwnership(manifest);
const ceiling = initialViewCeilingViolation(result.initialViewGzipBytes);
if (ceiling) throw new Error(ceiling.text);
const headroom = INITIAL_VIEW_GZIP_BUDGET - result.initialViewGzipBytes;
const lines = [
`initial View: ${result.initialViewGzipBytes} B gzip`
+ ` (потолок ${INITIAL_VIEW_GZIP_CEILING} B ±${INITIAL_VIEW_CEILING_BAND},`
+ ` (потолок ${INITIAL_VIEW_GZIP_CEILING} B +${INITIAL_VIEW_CEILING_BAND},`
+ ` budget ${INITIAL_VIEW_GZIP_BUDGET} B, headroom ${headroom} B)`,
`initial panel: ${result.initialPanelGzipBytes} B gzip`,
`initial panel-only: ${result.initialPanelOnlyGzipBytes} B gzip`
+ ` (budget ${INITIAL_PANEL_ONLY_GZIP_BUDGET} B,`
+ ` headroom ${INITIAL_PANEL_ONLY_GZIP_BUDGET - result.initialPanelOnlyGzipBytes} B)`,
`lazy editor: ${result.lazyEditorGzipBytes} B gzip (потолок ${LAZY_EDITOR_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND})`,
`lazy editor: ${result.lazyEditorGzipBytes} B gzip (потолок ${LAZY_EDITOR_GZIP_CEILING} B +${LAZY_GRAPH_CEILING_BAND})`,
`lazy onboarding: ${result.lazyOnboardingGzipBytes} B gzip`
+ ` (потолок ${LAZY_ONBOARDING_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND})`,
+ ` (потолок ${LAZY_ONBOARDING_GZIP_CEILING} B +${LAZY_GRAPH_CEILING_BAND})`,
`lazy namespace locales: ${result.lazyNamespaceLocaleGzipBytes} B gzip`
+ ` (${NAMESPACE_LOCALE_CHUNKS.length} chunks, по одному грузится на пространство)`,
`lazy furniture art: ${result.lazyFurnitureArtGzipBytes} B gzip`
+ ` (потолок ${LAZY_FURNITURE_ART_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND})`,
+ ` (потолок ${LAZY_FURNITURE_ART_GZIP_CEILING} B +${LAZY_GRAPH_CEILING_BAND})`,
`lazy locale: ${result.lazyLocaleGzipBytes} B gzip`,
`lazy isometric: ${result.lazyIsometricGzipBytes} B gzip`,
`lazy PDF: ${result.lazyPdfGzipBytes} B gzip`,
+43
View File
@@ -0,0 +1,43 @@
// Классы изменений, PROCESS.md §1 — одна таблица на все гейты (#701).
//
// Вынесена из process-gate.mjs: её читает и `validate-commit-provenance.mjs`
// (хук commit-msg), которому process-gate сам импортирует — общий модуль
// снимает круговую зависимость.
// Порядок важен: D проверяется первым, иначе собранный бандл попадёт в A,
// а demo/golden/baselines — в B.
const CLASS_D = [
/^dist\//,
/^custom_components\/houseplan\/frontend\//,
/^demo\/srv\/assets\/houseplan-card\.js$/,
/^demo\/golden\/baselines\//,
];
const CLASS_A = [
/^src\//,
/^custom_components\/houseplan\/.*\.py$/,
/^hacs\.json$/,
/^custom_components\/.*\/manifest\.json$/,
/^custom_components\/.*\/translations\//,
];
const CLASS_B = [
/^test\//, /^tests_backend\//, /^demo\//, /^scripts\//,
/^\.github\//, /^\.githooks\//, /^rollup\.config\.mjs$/, /^tsconfig.*\.json$/,
/^package(-lock)?\.json$/, /^pytest\.ini$/, /^\.gitignore$/, /^\.gitattributes$/,
// Пины toolchain — производные от validate.yml (#496), конфиг сборки.
/^\.nvmrc$/, /^\.python-version$/,
];
const CLASS_C = [
/^docs\//, /^README/, /^CHANGELOG/, /^AGENTS\.md$/, /^LICENSE$/,
/^CONTRIBUTING\.md$/, /^PROCESS.*\.md$/, /^(CODE|SPEC)-REVIEW-.*\.md$/,
// #682: архив выпущенного — документы ревью и ТЗ прошлых линий. Только
// Markdown; исполняемого там нет (#678 вынес всё прочее из дерева).
/^legacy\//,
];
export function classify(path) {
if (CLASS_D.some((r) => r.test(path))) return 'D';
if (CLASS_A.some((r) => r.test(path))) return 'A';
if (CLASS_B.some((r) => r.test(path))) return 'B';
if (CLASS_C.some((r) => r.test(path))) return 'C';
return '?';
}
+9 -3
View File
@@ -9,7 +9,12 @@ import { freshnessSink, screenshotsMode } from './docs-freshness.mjs';
import { guideParityErrors } from './user-guide-parity.mjs';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const EXTERNAL = process.argv.includes('--external');
// `--external` — внешние ссылки судятся ошибкой; `--external=warn` — только
// предупреждением (#700): упавший чужой сайт не красит ветку задачи, к чьему
// изменению он отношения не имеет. Блокируют внешние ссылки push в dev,
// кандидат беты и релиз — там их чинит релиз-менеджер.
const EXTERNAL_WARN = process.argv.includes('--external=warn');
const EXTERNAL = EXTERNAL_WARN || process.argv.includes('--external');
const PUBLIC_DOCS = [
'README.md', 'README.ru.md', 'docs/USER-GUIDE.md', 'docs/USER-GUIDE.ru.md',
'docs/TOUCH-SUPPORT.md', 'docs/DECOR-EDITOR.md', 'docs/VACUUM.md',
@@ -233,6 +238,7 @@ if (!existsSync(manifestPath)) {
}
if (EXTERNAL) {
const externalErrors = EXTERNAL_WARN ? warnings : errors;
const allowlist = JSON.parse(canonicalText(resolve(ROOT, 'docs/external-link-allowlist.json')));
const transientHosts = new Set(allowlist.transientHosts || []);
for (const href of [...externalUrls].sort()) {
@@ -249,10 +255,10 @@ if (EXTERNAL) {
continue;
}
}
errors.push(`external link returned ${response.status}: ${href}`);
externalErrors.push(`external link returned ${response.status}: ${href}`);
} catch (error) {
if (transientHosts.has(url.hostname)) warnings.push(`transient external failure: ${href} (${error.message})`);
else errors.push(`external link failed: ${href} (${error.message})`);
else externalErrors.push(`external link failed: ${href} (${error.message})`);
}
}
}
+4 -1
View File
@@ -25,9 +25,12 @@ const ROOT = fileURLToPath(new URL('..', import.meta.url));
/** Маршруты входа по роли. `budget: null` — только замер, без порога. */
export const ROUTES = Object.freeze({
// #701: `docs/STATUS.md` ушёл из входа автора — снимок версий и цикла нужен,
// когда сессия возобновляет работу или готовит релиз, а задачу ведёт её пакет
// (`task-packet.mjs`). Минус 691 слово на каждом входе.
author: {
budget: 12000, // AC1 #634
files: ['docs/SCOPE.md', 'AGENTS.md', 'docs/process/AUTHOR.md', 'docs/STATUS.md'],
files: ['docs/SCOPE.md', 'AGENTS.md', 'docs/process/AUTHOR.md'],
},
reviewer: {
budget: 9000,
+11 -4
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node
// Локальный гейт лёгкого трека одной командой (#479): `npm run gate:small`.
//
// PROCESS §8 перечисляет автору шесть команд, и в #476 они гонялись
// Этот файл — единственный источник состава обязательной части §8 (#701):
// канон и конспекты его не переписывают, а называют. В #476 команды гонялись
// последовательно, вперемешку с гейтами, к задаче не относящимися. Здесь
// обязательная часть §8 начинается параллельно — сборка с typecheck, «новый
// код не добавляет any», выбор смоков по диффу. Юниты читают свежий `dist`,
@@ -35,10 +36,13 @@ export function parseArgs(argv) {
return { base, smokes, jobs: Number.isInteger(jobs) && jobs > 0 ? jobs : 2 };
}
/** Смоки для прогона из JSON smoke-select: прямые + зарегистрированные, без «широких». */
/**
* Смоки для прогона из JSON smoke-select: прямые + зарегистрированные, без
* «широких»; при недоказанной связи — ещё визуальный минимум (#690 п.1′).
*/
export function smokesToRun(selection) {
if (!selection || selection.noExecutableDiff) return [];
const names = [...(selection.direct || []), ...(selection.registered || [])]
const names = [...(selection.direct || []), ...(selection.registered || []), ...(selection.visualMinimum || [])]
.map((entry) => (typeof entry === 'string' ? entry : entry.smoke))
.filter(Boolean);
return [...new Set(names)].sort();
@@ -143,7 +147,10 @@ export async function gateSmall({ cwd = ROOT, base = 'origin/dev', smokes = fals
else if (selection.noExecutableDiff) log('smoke-select: исполняемого frontend-диффа нет — смоки этим диффом не выбираются');
else {
const names = smokesToRun(selection);
log(`smoke-select: прямые и зарегистрированные (${names.length})${smokes ? ' — прогнаны выше' : ' — гоняются автором (или `--smokes`), решение по каждой строке в ревью'}:`);
const what = (selection.visualMinimum || []).length
? 'связь не доказана — прямые, зарегистрированные и визуальный минимум (#690)'
: 'прямые и зарегистрированные';
log(`smoke-select: ${what} (${names.length})${smokes ? ' — прогнаны выше' : ' — гоняются автором (или `--smokes`), решение по каждой строке в ревью'}:`);
for (const name of names) log(` demo/${name}`);
if ((selection.broad || []).length) log(` «широких» символов: ${selection.broad.length} — решает ревьюер, автоматически не гоняются`);
}
+32 -6
View File
@@ -34,6 +34,10 @@ import { CONVEYOR_IDENTITY } from './reviews-index.mjs';
const REVIEWS_INDEX_SCRIPT = fileURLToPath(new URL('./reviews-index.mjs', import.meta.url));
export const MAX_ATTEMPTS = 3;
/** Пути вне patch-id кандидата (#698): документы ревью и то, что ребейз сливает сам. */
export const PATCH_ID_EXCLUDES = Object.freeze([
':!docs/reviews', ':!docs/CHANGELOG.md', ':!docs/CHANGELOG.ru.md', ':!scripts/monolith-baseline.json',
]);
export const VALIDATE_APPEAR_MS = 3 * 60 * 1000;
export const VALIDATE_TOTAL_MS = 45 * 60 * 1000;
@@ -97,7 +101,9 @@ export function commentFor(action, ctx) {
case 'fast-forward':
return `материал \`${short(ctx.material)}\` · dev@\`${short(ctx.devNow)}\` → кандидат \`${short(ctx.candidate)}\``
+ (action === 'push' ? ` · Validate ${ctx.runUrl} зелёный` : ' · dev не двигался')
+ ' · слито';
+ ' · слито'
+ (ctx.branchDeleted === true ? ` · ветка \`${ctx.branch}\` удалена` : '')
+ (ctx.branchDeleted === false ? ` · ветка \`${ctx.branch}\` оставлена: её вершина сдвинулась после слияния` : '');
default:
return '';
}
@@ -145,8 +151,11 @@ export function realOps({
// Документы ревью — не часть патча (#516): кандидат несёт свой
// CODE-REVIEW-N-rK.md, материал — нет, и без pathspec их patch-id
// расходились на каждом сдвиге dev; `reviewedFresh` судит так же.
// #698: вердикт судит работу задачи. Ченджлоги объединяет `merge=union`,
// базу метрик монолита ребейз берёт из dev: строки соседей рядом с записью
// задачи меняют контекст диффа, но не то, что читал ревьюер.
patchId: (from, to) => {
const diff = must(git('diff', '--full-index', from, to, '--', '.', ':!docs/reviews'), 'diff');
const diff = must(git('diff', '--full-index', from, to, '--', '.', ...PATCH_ID_EXCLUDES), 'diff');
const r = spawnSync('git', ['patch-id', '--stable'], { input: diff, encoding: 'utf8' });
return (r.stdout || '').trim().split(' ')[0] || 'empty';
},
@@ -179,6 +188,14 @@ export function realOps({
must(exec(process.execPath, [REVIEWS_INDEX_SCRIPT, '--dir=docs/reviews', '--commit-if-stale', `--issue=${issue}`]), 'reviews-index --commit-if-stale');
return must(git('rev-parse', 'HEAD'), 'rev-parse HEAD');
},
// #702: ветка задачи удаляется после слияния — только если её вершина всё
// ещё та, что влита (lease): коммит, прилетевший после, не теряется.
deleteBranch: (ref, expected) => {
const r = git('push', '-q', `--force-with-lease=refs/heads/${ref}:${expected}`, pushUrl, `:refs/heads/${ref}`);
if (r.status === 0) return true;
if (/stale info|rejected|fetch first|lease/i.test(r.stderr)) return false;
throw new Error(`git push :${ref}: ${r.stderr}`);
},
pushWithLease: (sha, ref, expected) => {
const r = git('push', '-q', `--force-with-lease=refs/heads/${ref}:${expected}`, pushUrl, `${sha}:refs/heads/${ref}`);
if (r.status === 0) return true;
@@ -243,9 +260,18 @@ export async function mergeCandidate({ branch, material, issue, ops, maxAttempts
&& ops.diffNames(material, actual, ['.', ':!docs/reviews']).length === 0);
const ctx = { branch, material, actual, issue };
const finish = (decision, extra = {}) => {
const body = commentFor(decision.action, { ...ctx, ...extra, attempt: extra.attempt });
if (body) ops.comment(issue, body);
const merged = decision.action === 'push' || decision.action === 'fast-forward';
// #702: влитая ветка больше не нужна — 368 таких висели на origin, и агент,
// искавший ветку по номеру, мог взять устаревшую. `branchTip` — вершина,
// которую слияние видело последней: кандидат, опубликованный в ветку, либо
// материал при fast-forward. Сбой удаления слияние не отменяет.
let branchDeleted = null;
if (merged && extra.branchTip) {
try { branchDeleted = ops.deleteBranch(branch, extra.branchTip); }
catch (error) { ops.log(`ветка ${branch} не удалена: ${error.message}`); }
}
const body = commentFor(decision.action, { ...ctx, ...extra, attempt: extra.attempt, branchDeleted });
if (body) ops.comment(issue, body);
ops.log(`решение: ${decision.action} → ${decision.to || '(метка по вердикту)'}`);
return { merged, to: decision.to, action: decision.action, candidate: extra.candidate || actual };
};
@@ -265,7 +291,7 @@ export async function mergeCandidate({ branch, material, issue, ops, maxAttempts
const pushed = ops.pushWithLease(target, 'dev', devNow);
const decision = decideMerge({ fresh: true, devMoved: false, leaseRejected: !pushed });
if (decision.action === 'retry') continue;
return finish(decision, { candidate: target, devNow });
return finish(decision, { candidate: target, devNow, branchTip: tip });
}
const candidate = ops.rebaseOnto(tip, 'origin/dev');
@@ -310,7 +336,7 @@ export async function mergeCandidate({ branch, material, issue, ops, maxAttempts
const pushed = ops.pushWithLease(candidate, 'dev', devNow);
decision = decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: result, leaseRejected: !pushed, attempt, maxAttempts });
if (decision.action === 'retry') { ops.log('dev двинулся снова — ещё попытка'); continue; }
return finish(decision, { candidate, devNow, runUrl: url, attempt });
return finish(decision, { candidate, devNow, runUrl: url, attempt, branchTip: candidate });
}
return finish(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: maxAttempts, maxAttempts }), { candidate: tip, attempt: maxAttempts });
}
+15 -4
View File
@@ -261,17 +261,28 @@ export function collectMetrics(root, { diagnostics = null, distDir = 'dist' } =
*/
export const BUNDLE_BYTES_BAND = 2_000;
/** Сравнение с базой: что выросло, что упало. Отсутствующее в базе — рост. */
export function compareWithBaseline(current, baseline, { bundleBand = BUNDLE_BYTES_BAND } = {}) {
/**
* #699 (решение владельца 2026-09-28): полоса над базой беты у всех шести чисел.
* Рост `hostRefs` на несколько ссылок больше не блокирует задачу `show`, а
* параллельные задачи не конфликтуют на базе и не пересчитывают её после
* ребейза. Снижение задачу не красит: базу до факта опускает бета
* (`node scripts/ratchets.mjs tighten`).
*/
export const METRIC_BANDS = Object.freeze({
delegates: 5, portMembers: 5, hostRefs: 25, portPrivates: 5, harnessPrivates: 5, bundleBytes: BUNDLE_BYTES_BAND,
});
/** Сравнение с базой: что выросло сверх полосы, что упало. Отсутствующее в базе — рост. */
export function compareWithBaseline(current, baseline, { bands = METRIC_BANDS } = {}) {
const grown = [];
const shrunk = [];
for (const name of METRIC_NAMES) {
const now = current[name];
const base = baseline?.[name];
if (now == null) continue;
const band = name === 'bundleBytes' ? bundleBand : 0;
const band = bands[name] ?? 0;
if (base == null || now > base + band) grown.push({ name, base: base ?? null, now });
else if (now < base - band) shrunk.push({ name, base, now });
else if (now < base) shrunk.push({ name, base, now });
}
return { grown, shrunk };
}
+7
View File
@@ -4,6 +4,13 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* Ориентир, а не стена (#699, решение владельца 2026-09-28). Прежний жёсткий
* лимит заставлял новую задачу удалять чужой браузерный мутант или уводить свой
* свидетель в Node только ради числа (#687, #689). Каждый browser guard и так
* обязан иметь строку обоснования в реестре; сверх ориентира `--check`
* предупреждает, а не краснеет.
*/
export const BROWSER_GUARD_LIMIT = 200;
export const BROWSER_GUARD_INVENTORY = 'docs/testing-notes/mutation-browser-guards.md';
+5 -2
View File
@@ -42,8 +42,11 @@ export function checkMutationRegistry(selected, { allMutants, root, log = consol
const policy = browserGuardPolicy(allMutants, readDocumentedBrowserGuards(root));
log(`browser guards: ${policy.count}/${BROWSER_GUARD_LIMIT}`);
if (policy.overLimit) {
log(`FAIL browser guards: лимит ${BROWSER_GUARD_LIMIT} превышен`);
stale++;
// #699: ориентир, а не лимит — цена браузерных свидетелей растёт, но
// удалять чужой мутант ради числа не нужно.
log(`WARN browser guards: ${policy.count} при ориентире ${BROWSER_GUARD_LIMIT} — `
+ 'каждый сверх ориентира держится своей строкой обоснования в реестре');
warned++;
}
for (const mutant of policy.missingReasons) {
log(`WARN ${mutant.id}: browser guard не размечен — добавьте причину в `
+247 -7
View File
@@ -3623,11 +3623,11 @@ const MUTANT_DEFINITIONS = [
{
id: 'task-packet-trivial-is-product-flow',
guard: 'node --test test/task-packet.test.mjs',
because: 'the trivial short track writes no spec and no spec review, so in S6/S7 its label is '
because: 'a legacy trivial task wrote no spec and no spec review, so in S6/S7 its label is '
+ 'the only product-flow evidence; dropping it prints the false class A ban of #632 again (r1)',
patches: [{
file: 'scripts/task-packet.mjs',
find: " if (labels.includes('trivial')) reasons.push('короткий трек trivial (ТЗ не пишется, §5.1)');",
find: " if (labels.includes('trivial')) reasons.push('прежняя метка trivial — продуктовый поток, читается как track:show (§5.1)');",
replace: "",
}],
},
@@ -4069,6 +4069,29 @@ const MUTANT_DEFINITIONS = [
replace: ' return safe;',
}],
},
// #706: исход rereview возвращает задачу в ту же метку; один вызов gh её снимал.
{
id: 'rereview-relabel-in-one-call',
guard: 'node --test --test-name-pattern="#706 rereview" test/status-label.test.mjs',
because: '#706: --add-label X --remove-label X in one gh call leaves the issue without a status; '
+ 'the same label must be removed and set again so the labeled event starts a new round',
patches: [{
file: 'scripts/status-label.mjs',
find: ' if (from === to) {',
replace: ' if (false) { // mutant: one combined call',
}],
},
{
id: 'process-label-step-combined-again',
guard: 'node --test --test-name-pattern="#706 шаг конвейера" test/status-label.test.mjs',
because: '#706: the pipeline step must move the label through status-label.mjs, not through the '
+ 'combined gh call that stripped S7-code-review on rereview',
patches: [{
file: '.github/workflows/_process.yml',
find: ' node scripts/status-label.mjs --repo="${{ github.repository }}" \\\n --issue="$NUM" --from="$FROM" --to="$TO"\n',
replace: ' gh issue edit "$NUM" --repo "${{ github.repository }}" \\\n --add-label "$TO" --remove-label "$FROM"\n',
}],
},
{
id: 'vac-trail-drop-warn-removed',
guard: 'node --test --test-name-pattern="#369" test/vacuum.test.mjs',
@@ -5509,6 +5532,18 @@ const MUTANT_DEFINITIONS = [
replace: " const pushed = ops.pushWithLease(candidate, 'dev', devNow) || true; // mutant: lease never rejected\n decision = decideMerge(",
}],
},
// #702: влитая ветка задачи удаляется слиянием.
{
id: 'merged-task-branch-kept',
guard: 'node --test --test-name-pattern="#702" test/merge-candidate.test.mjs',
because: '#702: 368 merged issue/* branches piled up on origin; an agent looking a branch up by '
+ 'number could take a stale one — the merge deletes the branch it merged',
patches: [{
file: 'scripts/merge-candidate.mjs',
find: ' if (merged && extra.branchTip) {',
replace: ' if (false && merged && extra.branchTip) { // mutant: branch kept',
}],
},
{
id: 'nightly-does-not-wait',
guard: 'node --test --test-name-pattern="nightly ждёт запущенный Validate" test/nightly-workflow.test.mjs',
@@ -6132,6 +6167,40 @@ const MUTANT_DEFINITIONS = [
replace: " status: degradedExtraCount || degradedCoreCount ? 'failed-core' : 'ok',",
}],
},
// #690 п.1′: недоказанная связь смоков выдаёт визуальный минимум, а не пустоту.
{
id: 'visual-minimum-silent-again',
guard: 'node --test --test-name-pattern="#690" test/smoke-select.test.mjs',
because: '#690: an executable diff with no proven smoke link printed only "the reviewer decides" '
+ 'and ran nothing; #687 missed smoke_modes that way and left dev red for #685',
patches: [{
file: 'scripts/smoke-select.mjs',
find: ' visualMinimum: unproven ? [...VISUAL_MINIMUM] : [],',
replace: ' visualMinimum: [],',
}],
},
{
id: 'visual-minimum-on-proven-link',
guard: 'node --test --test-name-pattern="#690" test/smoke-select.test.mjs',
because: '#690: the minimum is for the unproven case only; with a proven link the selection must '
+ 'stay smaller than the matrix',
patches: [{
file: 'scripts/smoke-select.mjs',
find: ' visualMinimum: unproven ? [...VISUAL_MINIMUM] : [],',
replace: ' visualMinimum: parsed.executable.length ? [...VISUAL_MINIMUM] : [],',
}],
},
{
id: 'gate-small-skips-visual-minimum',
guard: 'node --test --test-name-pattern="#690" test/smoke-select.test.mjs',
because: '#690: gate:small -- --smokes must run the visual minimum it was handed, not only the '
+ 'direct and registered smokes',
patches: [{
file: 'scripts/gate-small.mjs',
find: ' const names = [...(selection.direct || []), ...(selection.registered || []), ...(selection.visualMinimum || [])]',
replace: ' const names = [...(selection.direct || []), ...(selection.registered || [])]',
}],
},
{
id: 'wall-isolated-extra-discarded',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
@@ -6928,6 +6997,19 @@ const MUTANT_DEFINITIONS = [
replace: " if (false && input.type === 'passage') {",
}],
},
// #693: курсор move над лестницей — только у редактора плана.
{
id: 'view-stair-cursor-move-again',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#693" test/stairs.test.mjs',
because: '#693: the View layer sets input-enabled only to receive clicks; an unscoped move cursor on '
+ '.hp-stair-hit hid the link pointer and showed a drag the View cannot do',
patches: [{
file: 'src/styles/plan.styles.ts',
find: ' .hp-stair.input-enabled:not(.hp-stair-view) .hp-stair-hit { cursor: move; }',
replace: ' .hp-stair.input-enabled .hp-stair-hit { cursor: move; }',
}],
},
{
id: 'entity-marker-kept-in-parent-device',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
@@ -8146,6 +8228,29 @@ const MUTANT_DEFINITIONS = [
replace: ' .hdr > .head { flex-wrap: wrap; padding: 5px 8px; gap: 6px; }',
}],
},
// #701: документационный коммит трейлеров не требует — и только он.
{
id: 'docs-only-commit-needs-trailers-again',
guard: 'node --test --test-name-pattern="#701" test/commit-provenance.test.mjs',
because: '#701 (PROCESS §3 п.10): rule #1 guards product code, not a typo in a guide; a docs-only '
+ 'commit carries no Issue/User-Visible trailers',
patches: [{
file: 'scripts/validate-commit-provenance.mjs',
find: ' const exempt = isDocsOnlyCommit(changedFiles) && !issues.length && !visible.length;',
replace: ' const exempt = false; // mutant: every commit needs trailers',
}],
},
{
id: 'docs-only-exemption-leaks-to-code',
guard: 'node --test --test-name-pattern="#701" test/commit-provenance.test.mjs',
because: '#701: one file outside class C makes the commit subject to rule #1 again; the exemption '
+ 'must not cover a commit that also touches src/**',
patches: [{
file: 'scripts/validate-commit-provenance.mjs',
find: " return changedFiles.length > 0 && changedFiles.every((file) => classify(file.replaceAll('\\\\', '/')) === 'C');",
replace: " return changedFiles.length > 0 && changedFiles.some((file) => classify(file.replaceAll('\\\\', '/')) === 'C'); // mutant",
}],
},
{
id: 'header-menu-drops-pdf',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
@@ -9028,7 +9133,7 @@ const MUTANT_DEFINITIONS = [
+ 'release where it would have fired, and the growth of #438 went unnoticed for a beta)',
patches: [{
file: 'scripts/bundle-budget.mjs',
find: ' const ceiling = initialViewCeilingViolation(result.initialViewGzipBytes);\n'
find: ' const ceiling = initialViewCeilingViolation(manifest.initialViewGzipBytes);\n'
+ ' if (ceiling) throw new Error(ceiling.text);\n',
replace: '',
}],
@@ -10348,15 +10453,82 @@ const MUTANT_DEFINITIONS = [
},
{
id: 'monolith-metrics-baseline-strict',
guard: 'node --test --test-name-pattern="#624 храповик" test/monolith-metrics.test.mjs',
because: 'the ratchet reddens on a growth of one; a >= comparison lets bundle bytes and '
+ 'coupling creep up one unit per commit (#624 AC1-b, AC2)',
guard: 'node --test --test-name-pattern="#699 храповик" test/monolith-metrics.test.mjs',
because: 'the ratchet reddens one unit over the band; a looser comparison lets bundle bytes and '
+ 'coupling creep up one unit per commit (#624 AC1-b, AC2; band since #699)',
patches: [{
file: 'scripts/monolith-metrics.mjs',
find: " if (base == null || now > base + band) grown.push({ name, base: base ?? null, now });",
replace: " if (base == null || now > base + band + 1) grown.push({ name, base: base ?? null, now }); // mutant: one unit of slack",
}],
},
// #699: полоса над потолком беты вместо двустороннего храповика с нулевым запасом.
{
id: 'core-band-ignored',
guard: 'node --test --test-name-pattern="#699" test/core-file-budget.test.mjs',
because: '#699: the core may grow up to 50 lines over the beta ceiling; without the band every '
+ 'task behind a neighbour hits a zero-slack ceiling again (#689 after #691)',
patches: [{
file: 'test/core-file-budget.test.mjs',
find: ' if (lines > cap + band) {',
replace: ' if (lines > cap) { // mutant: no band',
}],
},
{
id: 'initial-view-band-below-ceiling-again',
guard: 'node --test --test-name-pattern="#438/#699" test/bundle-assets.test.mjs',
because: '#699: the bundle band sits over the beta ceiling; a task no longer re-centres the '
+ 'ceiling to grow inside it',
patches: [{
file: 'scripts/bundle-budget.mjs',
find: ' if (bytes > ceiling + band) {\n return {\n kind: \'grew\',\n over: bytes - ceiling,\n text: `initial View graph',
replace: ' if (bytes > ceiling) {\n return {\n kind: \'grew\',\n over: bytes - ceiling,\n text: `initial View graph',
}],
},
{
id: 'monolith-band-exact-again',
guard: 'node --test --test-name-pattern="#699 храповик" test/monolith-metrics.test.mjs',
because: '#699: a few more host. references no longer block a show task; the five source '
+ 'numbers get a band over the beta baseline',
patches: [{
file: 'scripts/monolith-metrics.mjs',
find: ' const band = bands[name] ?? 0;',
replace: " const band = name === 'bundleBytes' ? bands[name] : 0; // mutant: exact again",
}],
},
{
id: 'monolith-shrink-fails-branch-again',
guard: 'node --test --test-name-pattern="#699 храповик" test/monolith-metrics.test.mjs',
because: '#699: a lower number is fixed at the beta by ratchets tighten; failing the branch '
+ 'makes parallel tasks conflict on the baseline again',
patches: [{
file: 'scripts/unused-locals-gate.mjs',
find: " for (const s of shrunk) lines.push(`info связность ниже базы: ${s.name} ${s.base} → ${s.now} — базу опустит бета`);",
replace: " fail = true; for (const s of shrunk) lines.push(`info связность ниже базы: ${s.name} ${s.base} → ${s.now} — базу опустит бета`); // mutant",
}],
},
{
id: 'ratchet-report-calls-band-tight',
guard: 'node --test --test-name-pattern="#699 состояние" test/ratchets.test.mjs',
because: '#699: a fact inside the band over the ceiling must be reported for the beta to raise '
+ 'or revert, not silently counted as tight',
patches: [{
file: 'scripts/ratchets.mjs',
find: " if (fact > ceiling) return 'over';\n",
replace: '',
}],
},
{
id: 'release-runbook-forgets-tighten',
guard: 'node --test --test-name-pattern="#699 r1 M1" test/ratchets.test.mjs',
because: '#699 r1 M1: the second side of the ratchet lives in the candidate checklist; a warning '
+ 'at publication time comes after the candidate commit and is ignored systematically',
patches: [{
file: 'docs/DEVELOPMENT.md',
find: 'facts with `node scripts/ratchets.mjs tighten` (#699',
replace: 'facts with `node scripts/ratchets.mjs report` (#699',
}],
},
{
id: 'monolith-delegates-return-only',
guard: 'node --test --test-name-pattern="#624 делегаты" test/monolith-metrics.test.mjs',
@@ -12129,10 +12301,44 @@ const MUTANT_DEFINITIONS = [
+ 'that counts docs/reviews sends every green candidate back to review whenever dev moved (#516)',
patches: [{
file: 'scripts/merge-candidate.mjs',
find: " const diff = must(git('diff', '--full-index', from, to, '--', '.', ':!docs/reviews'), 'diff');",
find: " const diff = must(git('diff', '--full-index', from, to, '--', '.', ...PATCH_ID_EXCLUDES), 'diff');",
replace: " const diff = must(git('diff', '--full-index', from, to), 'diff'); // mutant: review docs count",
}],
},
// #698: ребейз сам сливает то, в чём две задачи не противоречат друг другу.
{
id: 'changelog-union-driver-dropped',
guard: 'node --test --test-name-pattern="#698: записи ченджлога" test/rebase-generated.test.mjs',
because: '#698: two tasks adding lines to ## Unreleased must not send the second one back to S6; '
+ 'the built-in union driver merges both sides',
patches: [{
file: '.gitattributes',
find: 'docs/CHANGELOG.md merge=union\n',
replace: '',
}],
},
{
id: 'monolith-baseline-conflict-is-manual-again',
guard: 'node --test --test-name-pattern="#698: конфликт в базе" test/rebase-generated.test.mjs',
because: '#698: the monolith metrics baseline is a snapshot, not a decision; on a rebase conflict '
+ 'dev wins and the band test on the candidate judges the merged tree',
patches: [{
file: 'scripts/rebase-generated.mjs',
find: "export const UPSTREAM_WINS = Object.freeze(['scripts/monolith-baseline.json']);",
replace: 'export const UPSTREAM_WINS = Object.freeze([]); // mutant: every baseline conflict is manual',
}],
},
{
id: 'merge-patch-id-sees-changelog',
guard: 'node --test --test-name-pattern="#698: patch-id" test/rebase-generated.test.mjs',
because: '#698: a neighbour line next to the task entry changes the diff context, not what the '
+ 'reviewer read; counting it re-sends a green task to review',
patches: [{
file: 'scripts/merge-candidate.mjs',
find: " ':!docs/reviews', ':!docs/CHANGELOG.md', ':!docs/CHANGELOG.ru.md', ':!scripts/monolith-baseline.json',",
replace: " ':!docs/reviews', ':!scripts/monolith-baseline.json',",
}],
},
{
id: 'merge-trusts-success-without-proof',
guard: 'node --test test/merge-candidate.test.mjs',
@@ -12637,6 +12843,40 @@ const MUTANT_DEFINITIONS = [
replace: " if (d.startsWith('M') || d.length >= 0) return d;",
}],
},
// #700: предполёт не красит ветку задачи чужими причинами.
{
id: 'task-branch-workflow-sync-red-again',
guard: 'node --test --test-name-pattern="#700" test/validate-workflow.test.mjs',
because: '#700: a thin workflow mirror mismatch has nothing to do with the task branch; 11 of 85 '
+ 'returns in #600–#691 came from it — on issue/* it is a warning, on dev a red preflight',
patches: [{
file: '.github/workflows/validate.yml',
find: ' advise "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC"',
replace: ' check "тонкие вызывающие workflow в main и dev" "$WORKFLOW_SYNC"',
}],
},
{
id: 'workflow-sync-issue-duplicated-on-read-failure',
guard: 'node --test --test-name-pattern="#700: на ветке задачи" test/validate-workflow.test.mjs',
because: 'r1 #700: a failed read of the open issues must not fall through into gh issue create — '
+ 'that duplicates the owner issue on every network or rate-limit failure',
patches: [{
file: '.github/workflows/validate.yml',
find: ' exit 0\n fi\n if [ -n "$existing" ]; then',
replace: ' :\n fi\n if [ -n "$existing" ]; then',
}],
},
{
id: 'external-link-warn-mode-ignored',
guard: 'node --test --test-name-pattern="#700: check-docs" test/validate-workflow.test.mjs',
because: '#700: a foreign site that is down must not turn a task branch red; --external=warn '
+ 'routes external failures to warnings',
patches: [{
file: 'scripts/check-docs.mjs',
find: ' const externalErrors = EXTERNAL_WARN ? warnings : errors;',
replace: ' const externalErrors = errors; // mutant: warn mode ignored',
}],
},
{
id: 'screenshot-freshness-never-strict',
guard: 'node --test --test-name-pattern="#586" test/classify-changes.test.mjs',
+5 -37
View File
@@ -39,36 +39,11 @@ import { fileURLToPath } from 'node:url';
import { resolveValidationRange } from './validate-commit-provenance.mjs';
// --- классы изменений, PROCESS.md §1 ---
// Порядок важен: D проверяется первым, иначе собранный бандл попадёт в A,
// а demo/golden/baselines — в B.
const CLASS_D = [
/^dist\//,
/^custom_components\/houseplan\/frontend\//,
/^demo\/srv\/assets\/houseplan-card\.js$/,
/^demo\/golden\/baselines\//,
];
const CLASS_A = [
/^src\//,
/^custom_components\/houseplan\/.*\.py$/,
/^hacs\.json$/,
/^custom_components\/.*\/manifest\.json$/,
/^custom_components\/.*\/translations\//,
];
const CLASS_B = [
/^test\//, /^tests_backend\//, /^demo\//, /^scripts\//,
/^\.github\//, /^\.githooks\//, /^rollup\.config\.mjs$/, /^tsconfig.*\.json$/,
/^package(-lock)?\.json$/, /^pytest\.ini$/, /^\.gitignore$/, /^\.gitattributes$/,
// Пины toolchain — производные от validate.yml (#496), конфиг сборки.
/^\.nvmrc$/, /^\.python-version$/,
];
const CLASS_C = [
/^docs\//, /^README/, /^CHANGELOG/, /^AGENTS\.md$/, /^LICENSE$/,
/^CONTRIBUTING\.md$/, /^PROCESS.*\.md$/, /^(CODE|SPEC)-REVIEW-.*\.md$/,
// #682: архив выпущенного — документы ревью и ТЗ прошлых линий. Только
// Markdown; исполняемого там нет (#678 вынес всё прочее из дерева).
/^legacy\//,
];
// Классы изменений (PROCESS.md §1) живут в change-classes.mjs (#701): их
// читает и хук commit-msg, который судит, нужен ли коммиту трейлер.
import { classify } from './change-classes.mjs';
export { classify };
const CHANGELOGS = ['docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md'];
@@ -90,13 +65,6 @@ export const RULES = {
10: 'DoR по моменту коммита',
};
export function classify(path) {
if (CLASS_D.some((r) => r.test(path))) return 'D';
if (CLASS_A.some((r) => r.test(path))) return 'A';
if (CLASS_B.some((r) => r.test(path))) return 'B';
if (CLASS_C.some((r) => r.test(path))) return 'C';
return '?';
}
// --- разбор коммитов ---
// Тело коммита многострочное, поэтому поля режутся не по переводам строк:
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env node
/**
* Храповики беты (#699, PROCESS.md §8).
*
* node scripts/ratchets.mjs report [--warn] # факт против потолков
* node scripts/ratchets.mjs tighten # потолки := факт кандидата
*
* С #699 задача может вырасти над потолком беты в пределах полосы, а снижение
* её не красит. Вторая сторона храповика живёт здесь: релиз-менеджер на
* кандидате беты опускает потолки до факта одной командой, и выигрыш,
* накопленный линией, фиксируется одним коммитом, а не правкой общих чисел в
* каждой ветке — на них параллельные задачи и конфликтовали.
*
* Потолки лежат там же, где их читают гейты: строки ядер — в
* `test/core-file-budget.test.mjs` (CAPS), gzip-графы — в
* `scripts/bundle-budget.mjs`, числа связности — в
* `scripts/monolith-baseline.json`. Бандл и `bundleBytes` меряются по
* собранному `dist/`: на кандидате он закоммичен свежим (`npm run bundle:release`).
*
* `report --warn` печатает `::warning::` на рыхлые и вышедшие в полосу потолки
* и всегда выходит с 0: это напоминание публикации беты, а не её гейт.
*/
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { isMainModule } from './spawn-portable.mjs';
import {
INITIAL_VIEW_CEILING_BAND, LAZY_GRAPH_CEILING_BAND,
} from './bundle-budget.mjs';
import {
BASELINE_FILE, METRIC_BANDS, METRIC_NAMES, collectMetrics, readBaseline,
} from './monolith-metrics.mjs';
export const CORE_BUDGET_FILE = 'test/core-file-budget.test.mjs';
export const BUNDLE_BUDGET_FILE = 'scripts/bundle-budget.mjs';
/** Полоса ядра — та же, что у теста (#699); тест сверяет, что числа не разошлись. */
export const CORE_BAND = 50;
export const BUNDLE_CEILINGS = Object.freeze([
{ name: 'INITIAL_VIEW_GZIP_CEILING', metric: 'initialViewGzipBytes', label: 'initial View', band: INITIAL_VIEW_CEILING_BAND },
{ name: 'LAZY_EDITOR_GZIP_CEILING', metric: 'lazyEditorGzipBytes', label: 'lazy editor', band: LAZY_GRAPH_CEILING_BAND },
{ name: 'LAZY_ONBOARDING_GZIP_CEILING', metric: 'lazyOnboardingGzipBytes', label: 'lazy onboarding', band: LAZY_GRAPH_CEILING_BAND },
{ name: 'LAZY_FURNITURE_ART_GZIP_CEILING', metric: 'lazyFurnitureArtGzipBytes', label: 'lazy furniture art', band: LAZY_GRAPH_CEILING_BAND },
]);
const capsBlock = (source) => {
const start = source.indexOf('const CAPS = {');
if (start < 0) throw new Error(`${CORE_BUDGET_FILE}: нет блока const CAPS`);
const end = source.indexOf('};', start);
return { start, end, text: source.slice(start, end) };
};
/** Потолки ядер из исходника теста: `'src/x.ts': 12891,`. */
export function readCoreCaps(source) {
const caps = {};
for (const match of capsBlock(source).text.matchAll(/^\s*'([^']+)':\s*(\d+),\s*$/gm)) caps[match[1]] = Number(match[2]);
return caps;
}
/** Тот же исходник с новыми числами; комментарии и порядок — как были. */
export function rewriteCoreCaps(source, facts) {
const { start, end, text } = capsBlock(source);
const next = text.replace(/^(\s*)'([^']+)':\s*(\d+),(\s*)$/gm,
(line, indent, file, value, tail) => (file in facts ? `${indent}'${file}': ${facts[file]},${tail}` : line));
return source.slice(0, start) + next + source.slice(end);
}
/** `301_000` → 301000. */
export function readConst(source, name) {
const match = new RegExp(`^export const ${name} = ([\\d_]+);$`, 'm').exec(source);
if (!match) throw new Error(`${BUNDLE_BUDGET_FILE}: нет export const ${name}`);
return Number(match[1].replaceAll('_', ''));
}
const grouped = (value) => String(value).replace(/\B(?=(\d{3})+(?!\d))/g, '_');
export function rewriteConst(source, name, value) {
readConst(source, name);
return source.replace(new RegExp(`^export const ${name} = [\\d_]+;$`, 'm'), `export const ${name} = ${grouped(value)};`);
}
/** Состояние одного храповика относительно потолка беты и полосы. */
export function ratchetState(fact, ceiling, band) {
if (!Number.isFinite(fact) || !Number.isFinite(ceiling)) return 'missing';
if (fact > ceiling + band) return 'grew';
if (fact > ceiling) return 'over';
if (fact < ceiling) return 'loose';
return 'tight';
}
export function ratchetRows({ coreFacts = {}, coreCaps = {}, bundleFacts = {}, bundleCeilings = {}, metrics = {}, baseline = {} }) {
const rows = [];
for (const [file, cap] of Object.entries(coreCaps)) {
rows.push({ kind: 'core', name: file, fact: coreFacts[file], ceiling: cap, band: CORE_BAND });
}
for (const entry of BUNDLE_CEILINGS) {
rows.push({ kind: 'bundle', name: entry.label, key: entry.name, fact: bundleFacts[entry.metric], ceiling: bundleCeilings[entry.name], band: entry.band });
}
for (const name of METRIC_NAMES) {
rows.push({ kind: 'monolith', name, fact: metrics[name], ceiling: baseline?.[name], band: METRIC_BANDS[name] ?? 0 });
}
return rows.map((row) => ({ ...row, state: ratchetState(row.fact, row.ceiling, row.band) }));
}
const STATE_TEXT = {
tight: 'равен факту',
loose: 'рыхлый — опустить до факта',
over: 'факт в полосе над потолком — поднять до факта или вернуть',
grew: 'факт выше полосы — гейт ветки обязан был покраснеть',
missing: 'не измерен',
};
export function formatRow(row) {
const delta = Number.isFinite(row.fact) && Number.isFinite(row.ceiling) ? row.fact - row.ceiling : null;
const sign = delta == null ? '' : ` (${delta > 0 ? '+' : ''}${delta}, полоса +${row.band})`;
return `${row.kind.padEnd(8)} ${row.name}: факт ${row.fact ?? '—'} · потолок ${row.ceiling ?? '—'}${sign} — ${STATE_TEXT[row.state]}`;
}
function measure(root) {
const coreSource = readFileSync(resolve(root, CORE_BUDGET_FILE), 'utf8');
const bundleSource = readFileSync(resolve(root, BUNDLE_BUDGET_FILE), 'utf8');
const coreCaps = readCoreCaps(coreSource);
const coreFacts = Object.fromEntries(Object.keys(coreCaps).map((file) => [
file, existsSync(resolve(root, file)) ? readFileSync(resolve(root, file), 'utf8').split('\n').length : undefined,
]));
const manifestPath = resolve(root, 'dist/houseplan-assets.json');
const bundleFacts = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, 'utf8')) : {};
const bundleCeilings = Object.fromEntries(BUNDLE_CEILINGS.map((entry) => [entry.name, readConst(bundleSource, entry.name)]));
const { metrics } = collectMetrics(root);
const baseline = readBaseline(root) || {};
return { coreSource, bundleSource, coreCaps, coreFacts, bundleFacts, bundleCeilings, metrics, baseline };
}
if (isMainModule(import.meta.url)) {
const [command] = process.argv.slice(2);
const root = process.cwd();
try {
const m = measure(root);
const rows = ratchetRows(m);
if (command === 'report') {
const warn = process.argv.includes('--warn');
for (const row of rows) console.log(formatRow(row));
const attention = rows.filter((row) => row.state !== 'tight');
if (warn) {
for (const row of attention) console.log(`::warning::храповик ${row.kind} ${row.name}: ${STATE_TEXT[row.state]} — node scripts/ratchets.mjs tighten`);
process.exit(0);
}
process.exit(rows.some((row) => row.state === 'grew' || row.state === 'missing') ? 1 : 0);
} else if (command === 'tighten') {
const missing = rows.filter((row) => row.state === 'missing');
if (missing.length) throw new Error(`не измерено: ${missing.map((row) => `${row.kind} ${row.name}`).join(', ')} — сначала npm run build`);
const coreFacts = Object.fromEntries(rows.filter((row) => row.kind === 'core').map((row) => [row.name, row.fact]));
writeFileSync(resolve(root, CORE_BUDGET_FILE), rewriteCoreCaps(m.coreSource, coreFacts));
let bundleSource = m.bundleSource;
for (const row of rows.filter((r) => r.kind === 'bundle')) bundleSource = rewriteConst(bundleSource, row.key, row.fact);
writeFileSync(resolve(root, BUNDLE_BUDGET_FILE), bundleSource);
const baseline = Object.fromEntries(METRIC_NAMES.map((name) => [name, m.metrics[name]]));
writeFileSync(resolve(root, BASELINE_FILE), `${JSON.stringify(baseline, null, 2)}\n`);
for (const row of rows.filter((r) => r.state !== 'tight')) console.log(`${row.kind} ${row.name}: ${row.ceiling} → ${row.fact}`);
console.log(`потолки опущены до факта: ${CORE_BUDGET_FILE}, ${BUNDLE_BUDGET_FILE}, ${BASELINE_FILE} — закоммитить вместе с кандидатом беты`);
} else {
throw new Error('usage: ratchets.mjs report [--warn] | tighten');
}
} catch (error) {
console.error(`::error::${error.message}`);
process.exit(command === 'report' && process.argv.includes('--warn') ? 0 : 1);
}
}
+21 -5
View File
@@ -10,8 +10,8 @@
// и не «их», а пересборка по дереву, в котором остановился ребейз.
//
// Правило одно: остановку разрешает только набор конфликтов, в котором ВСЕ
// пути — индекс (или пути, которые вызывающий объявил своими: бандл в
// `rebase-on-dev.mjs`). Хоть один другой путь — `git rebase --abort` и
// пути — индекс, данные, где права сторона dev (`UPSTREAM_WINS`, #698), или
// пути, которые вызывающий объявил своими (бандл в `rebase-on-dev.mjs`). Хоть один другой путь — `git rebase --abort` и
// перечень ВСЕХ конфликтующих файлов, индекс в нём тоже: автор видит полную
// картину, дерево и HEAD как были.
//
@@ -32,6 +32,15 @@ import { fileURLToPath } from 'node:url';
import { isMainModule } from './spawn-portable.mjs';
export const REVIEWS_INDEX_PATH = 'docs/reviews/INDEX.md';
/**
* #698: данные, в которых на конфликте права сторона `dev`. База метрик
* монолита — снимок чисел, а не решение задачи: правильное значение для
* объединённого дерева не «наше» и не «их», и судит его гейт связности на
* Validate кандидата (`lint:unused`; полоса у всех чисел — #699), а не автор
* руками. Ченджлоги сюда не входят —
* их объединяет `merge=union` в `.gitattributes`, и конфликта не бывает.
*/
export const UPSTREAM_WINS = Object.freeze(['scripts/monolith-baseline.json']);
/** Скрипт индекса — по абсолютному пути: ребейз идёт и из чужого cwd (worktree кандидата). */
export const REVIEWS_INDEX_SCRIPT = fileURLToPath(new URL('./reviews-index.mjs', import.meta.url));
/** Предохранитель от зацикливания: коммитов в ветке задачи единицы, не тысячи. */
@@ -46,18 +55,19 @@ const uniquePaths = (paths) => [...new Set(paths.map((p) => String(p).trim()).fi
*
* @param {string[]} paths конфликтующие пути (`git diff --name-only --diff-filter=U`)
* @param {{ extra?: (path: string) => boolean }} [opts] пути, которые вызывающий решает сам
* @returns {{ action: 'resolve', index: boolean, extra: string[], conflicts: string[] }
* @returns {{ action: 'resolve', index: boolean, upstream: string[], extra: string[], conflicts: string[] }
* | { action: 'abort', reason: 'no-conflicts'|'manual', manual: string[], conflicts: string[] }}
*/
export function planStop(paths, { extra = () => false } = {}) {
const conflicts = uniquePaths(paths);
if (!conflicts.length) return { action: 'abort', reason: 'no-conflicts', manual: [], conflicts };
const manual = conflicts.filter((path) => path !== REVIEWS_INDEX_PATH && !extra(path));
const manual = conflicts.filter((path) => path !== REVIEWS_INDEX_PATH && !UPSTREAM_WINS.includes(path) && !extra(path));
if (manual.length) return { action: 'abort', reason: 'manual', manual, conflicts };
return {
action: 'resolve',
index: conflicts.includes(REVIEWS_INDEX_PATH),
extra: conflicts.filter((path) => path !== REVIEWS_INDEX_PATH),
upstream: conflicts.filter((path) => UPSTREAM_WINS.includes(path)),
extra: conflicts.filter((path) => path !== REVIEWS_INDEX_PATH && !UPSTREAM_WINS.includes(path)),
conflicts,
};
}
@@ -127,6 +137,12 @@ export function rebaseRegenerating({
return abort({ reason: plan.reason, conflicts: plan.conflicts, manual: plan.manual, output: step.stderr || step.stdout });
}
for (const path of plan.extra) resolved.push(`${path} ← ${extra.resolve(path)}`);
// На ребейзе `--ours` — сторона, НА которую ребейзят, то есть dev.
for (const path of plan.upstream) {
git(['checkout', '--ours', '--', path]);
git(['add', '--', path]);
resolved.push(`${path} ← dev`);
}
if (plan.index) {
// Не --ours и не --theirs: ни одна сторона не знает документов другой.
rebuildIndex();
+2
View File
@@ -513,6 +513,8 @@ if (invokedDirectly) {
tag, candidate: sha, base: history.base, commits: history.commits, issueNumbers: issues,
}).manifest;
const existingRelease = releaseView();
// #699: храповики с полосой опускает до факта бета — напоминание, не гейт.
run(process.execPath, ['scripts/ratchets.mjs', 'report', '--warn'], { allowFailure: true, inherit: true });
console.log(JSON.stringify({
ready: true, tag, version: contract.version, sha, branch, bundleSha256,
+28 -2
View File
@@ -41,6 +41,26 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
*/
export const BROAD_SHARE = 0.2;
/**
* «Визуальный минимум» (#690 п.1′): смоки режимов, слоёв и отрисовки, которые
* выдаются, когда дифф исполняемый, а связь ни с одним смоком не доказана.
* Раньше такой ответ был только текстом «решает ревьюер», и автор гонял одни
* названные в ТЗ смоки: CSS-правка #687 не выбрала `smoke_modes`, и `dev`
* остался красным для следующей задачи (#685). Набор фиксирован и короткий —
* локально меньше минуты, — чтобы «неопределённость» стоила прогона, а не
* полной матрицы: полная матрица остаётся гейтом `dev` и беты.
*/
export const VISUAL_MINIMUM = Object.freeze([
'smoke_modes.mjs', // режимы: иконки плана, панели, выход в вид (#687)
'smoke_mode_transition.mjs', // вид ↔ редактор: высота тулбара, сцена, камера
'smoke_hide_layers.mjs', // слои плана по флагам
'smoke_decor_layer_order.mjs', // порядок слоёв декора
'smoke_daycycle_zoom_layers.mjs', // слои дня/ночи при любом масштабе (#689)
'smoke_static_zoom_sharpness.mjs', // свежий векторный кадр после зума (#685, #689)
'smoke_wall_hatch_density.mjs', // штриховка стен в обоих рендерах
'smoke_visual_continuity.mjs', // последний кадр плана не пропадает
]);
/** Файлы, чей дифф способен что-то сломать в браузере. */
const isExecutableFrontend = (file) => file.startsWith('src/')
&& file.endsWith('.ts') && !file.endsWith('.d.ts');
@@ -197,6 +217,8 @@ export function selectSmokes(diffText, { root = repoRoot, table, corpus } = {})
const registered = registeredSmokes(parsed.symbols)
.filter((entry) => !directNames.has(entry.smoke));
const unproven = parsed.executable.length > 0
&& !direct.some((entry) => entry.strong) && !registered.length;
return {
files: parsed.files,
executable: parsed.executable,
@@ -211,8 +233,9 @@ export function selectSmokes(diffText, { root = repoRoot, table, corpus } = {})
// нельзя молчать. `noExecutableDiff` от него отличается: там и правда
// нечего проверять (docs, i18n-строки без кода, чистая инфраструктура).
noExecutableDiff: parsed.executable.length === 0,
unproven: parsed.executable.length > 0
&& !direct.some((entry) => entry.strong) && !registered.length,
unproven,
// #690 п.1′: неопределённость не молчит и не отдаёт пустой набор.
visualMinimum: unproven ? [...VISUAL_MINIMUM] : [],
};
}
@@ -281,6 +304,9 @@ function report(selection) {
+ ' доказуемо.');
lines.push('Это не значит «смоки не нужны»: значит, что связь не доказана'
+ ' и решает ревьюер.');
lines.push(`Визуальный минимум (${selection.visualMinimum.length}) — прогнать до S7;`
+ ' `npm run gate:small -- --smokes` гоняет его сам (#690):');
for (const smoke of selection.visualMinimum) lines.push(` demo/${smoke}`);
if (selection.broad.length) {
lines.push(`Широкие символы (есть почти везде, ничего не различают):`
+ ` ${selection.broad.slice(0, 10).join(', ')}`);
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env node
/**
* Перестановка статусной метки после интеграции ревью (#706).
*
* node scripts/status-label.mjs --repo=owner/name --issue=NN --from=<метка> --to=<метка>
*
* Обычный исход — одна правка: `--add-label TO --remove-label FROM`. Исход
* `rereview` (#492) возвращает задачу в ту же метку, из которой она пришла:
* `TO == FROM == S7-code-review`. Одним вызовом `gh` такую метку добавляет и
* тут же снимает — задача оставалась без статуса, и нового захода не было
* (#699, 2026-09-28). Поэтому одинаковая метка снимается и ставится заново
* двумя вызовами, как в автосверке (`relabel`, #555): событие `labeled`
* запускает новый заход ревью, а сбой повторной постановки роняет шаг, и
* конвейер зовёт владельца.
*/
import { spawnSync } from 'node:child_process';
import { isMainModule } from './spawn-portable.mjs';
import { relabel } from './process-reconcile.mjs';
function gh(args, { allowFailure = false } = {}) {
const result = spawnSync('gh', args, { encoding: 'utf8' });
if (!allowFailure && (result.error || result.status !== 0)) {
throw new Error(`gh ${args.join(' ')} → ${(result.stderr || result.error?.message || '').trim()}`);
}
return result;
}
/** @returns {'moved' | 'relabeled'} */
export function moveStatusLabel({ repo, issue, from = '', to, execute = gh }) {
if (!repo || !issue || !to) throw new Error('usage: status-label.mjs --repo=owner/name --issue=NN --from=<метка> --to=<метка>');
if (from === to) {
relabel(repo, { number: issue }, to, execute);
return 'relabeled';
}
const args = ['issue', 'edit', String(issue), '--repo', repo, '--add-label', to];
if (from) args.push('--remove-label', from);
execute(args);
return 'moved';
}
if (isMainModule(import.meta.url)) {
const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? '';
try {
const how = moveStatusLabel({ repo: arg('repo'), issue: arg('issue'), from: arg('from'), to: arg('to') });
console.log(`${arg('from') || '(без метки)'} -> ${arg('to')}${how === 'relabeled' ? ' (снята и поставлена заново — новый заход, #706)' : ''}`);
} catch (error) {
console.error(`::error::${error.message}`);
process.exit(1);
}
}
+6 -5
View File
@@ -135,15 +135,16 @@ export { hasTrackLabel, trackFromLabels };
* сразу на S7 и никогда не несёт S1–S5, ТЗ и ревью ТЗ; поэтому любой из этих
* признаков делает эвристику «дифф без класса A» неприменимой. S6/S7/S8 сами по
* себе признаком не являются: их носит и инфраструктурная задача после ревью.
* Метка `trivial` — признак сама по себе (r1 #632): короткий трек (PROCESS §5.1)
* идёт S2 → S5 без ТЗ и без ревью ТЗ, и в S6/S7 никакого другого следа потока у
* него нет. Ускоренный инфраструктурный вход понятия трека не имеет, поэтому
* `trivial` на нём не бывает; `infra` рядом с ней — тематическая метка.
* Прежняя метка `trivial` — признак сама по себе (r1 #632): такие задачи шли
* S2 → S5 без ТЗ и без ревью ТЗ, и в S6/S7 никакого другого следа потока у них
* нет. С #695 `trivial` читается как `track:show` (PROCESS §5.1), новым задачам
* не ставится, но на старых остаётся. Инфраструктурный вход её не несёт; `infra`
* рядом с ней — тематическая метка.
*/
export function productFlowEvidence({ status = null, labels = [], issue = {}, specs = [], reviewDocs = [], comments = [] } = {}) {
const reasons = [];
if (PRE_CODE_STATUSES.includes(status)) reasons.push(`статус ${status}`);
if (labels.includes('trivial')) reasons.push('короткий трек trivial (ТЗ не пишется, §5.1)');
if (labels.includes('trivial')) reasons.push('прежняя метка trivial — продуктовый поток, читается как track:show (§5.1)');
if (/^#{1,3}\s*ТЗ(?![\p{L}\p{N}_])/mu.test(String(issue?.body ?? ''))) reasons.push('раздел «## ТЗ» в теле issue');
if (specs.length) reasons.push('файл ТЗ в docs/specs');
if (reviewDocs.some((d) => String(d.name).startsWith('SPEC-REVIEW-'))) reasons.push('документ ревью ТЗ');
+13 -12
View File
@@ -16,12 +16,12 @@
* который компилятор доказал мёртвым.
*
* 2. Храповик: шесть чисел `scripts/monolith-metrics.mjs` не растут
* относительно `scripts/monolith-baseline.json` (`bundleBytes` — с полосой
* ±2 000 Б, как gzip-потолок #438; остальные — точно). Снижение — не ошибка, но
* база обязана быть опущена тем же коммитом (`--update`): незафиксированный
* выигрыш монолит отыграет обратно первой же правкой. Рост допускается
* только с явной записью в issue задачи и правкой базы в том же коммите —
* гейт печатает, какое число и на сколько.
* относительно `scripts/monolith-baseline.json` больше своей полосы
* (`METRIC_BANDS`, #699). Снижение задачу не красит: базу до факта опускает
* бета (`node scripts/ratchets.mjs tighten`), иначе выигрыш монолит отыграет
* обратно. Рост сверх полосы допускается только с явной записью в issue
* задачи и правкой базы в том же коммите — гейт печатает, какое число и на
* сколько.
*
* `bundleBytes` требует собранного `dist/`: гейт стоит после `npm run build`
* (в `gate:small` и в job `frontend` Validate). Без сборки число не судится,
@@ -31,7 +31,7 @@ import { writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { isMainModule } from './spawn-portable.mjs';
import {
BASELINE_FILE, METRIC_NAMES, collectMetrics, compareWithBaseline, formatMetrics, readBaseline,
BASELINE_FILE, METRIC_NAMES, collectMetrics, compareWithBaseline, formatMetrics, readBaseline, METRIC_BANDS,
} from './monolith-metrics.mjs';
/**
@@ -60,15 +60,16 @@ export function decide({ metrics, violations, baseline }) {
fail = true;
for (const g of grown) {
lines.push(`FAIL связность выросла: ${g.name} ${g.base ?? 'нет в базе'} → ${g.now}`
+ ' — вернуть или обосновать в issue и поднять базу тем же коммитом');
+ ` — больше полосы ${METRIC_BANDS[g.name] ?? 0}; вернуть или обосновать в issue и поднять базу тем же коммитом`);
}
}
if (shrunk.length) {
// Снижение без записи в базу — тоже отказ: храповик работает в обе стороны.
fail = true;
for (const s of shrunk) lines.push(`FAIL связность упала, база не опущена: ${s.name} ${s.base} → ${s.now} — node scripts/unused-locals-gate.mjs --update`);
// #699: снижение задачу не красит — базу до факта опускает бета
// (`node scripts/ratchets.mjs tighten`). Храповик по-прежнему двусторонний,
// но вторая сторона живёт на бете, а не в каждой ветке.
for (const s of shrunk) lines.push(`info связность ниже базы: ${s.name} ${s.base} → ${s.now} — базу опустит бета`);
}
if (!grown.length && !shrunk.length) lines.push('ok все числа равны базе');
if (!grown.length && !shrunk.length) lines.push('ok все числа в полосе базы');
if (metrics.bundleBytes == null) {
fail = true;
lines.push('FAIL dist/ не собран — bundleBytes не судится; сначала npm run build');
+16 -3
View File
@@ -4,6 +4,7 @@ import { basename } from 'node:path';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { bundleCommitErrors } from './bundle-policy.mjs';
import { classify } from './change-classes.mjs';
const TRAILER = /^([A-Za-z][A-Za-z0-9-]*):\s*(.*?)\s*$/;
export const ENFORCEMENT_BOUNDARY = '8e2973fa7a7cb1a80204ff95ecf3f2d7c36ed2ce';
@@ -52,15 +53,27 @@ export function terminalTrailers(message) {
return out;
}
/**
* #701 (PROCESS.md §3 п.10): документационный коммит — только файлы класса C —
* трейлеров не требует, как `skip issue` у CPython. Правило №1 охраняет
* продуктовый код, а не опечатку в гайде. Пустой список файлов (сообщение без
* `--staged`) — не документационный коммит: судить нечем, правило прежнее.
* Трейлеры, если они есть, судятся всегда.
*/
export function isDocsOnlyCommit(changedFiles = []) {
return changedFiles.length > 0 && changedFiles.every((file) => classify(file.replaceAll('\\', '/')) === 'C');
}
export function validateCommitMessage(message, changedFiles = [], { baselineIndex = undefined, authorDate = null } = {}) {
const trailers = terminalTrailers(message);
const errors = [];
const issues = trailers.get('Issue') || [];
if (!issues.length || issues.some((value) => !/^#[1-9][0-9]*$/.test(value))) {
const visible = trailers.get('User-Visible') || [];
const exempt = isDocsOnlyCommit(changedFiles) && !issues.length && !visible.length;
if (!exempt && (!issues.length || issues.some((value) => !/^#[1-9][0-9]*$/.test(value)))) {
errors.push("missing or invalid terminal 'Issue: #<positive number>' trailer");
}
const visible = trailers.get('User-Visible') || [];
if (visible.length !== 1 || !/^(yes|no)$/.test(visible[0])) {
if (!exempt && (visible.length !== 1 || !/^(yes|no)$/.test(visible[0]))) {
errors.push("expected exactly one terminal 'User-Visible: yes|no' trailer");
}
const normalizedFiles = changedFiles.map((file) => file.replaceAll('\\', '/'));
+1 -1
View File
@@ -86,7 +86,7 @@ export class StairViewRuntime {
|| !stair.target_space_id) return;
this.owner._tabClick(stair.target_space_id);
};
return svg`<g class="hp-stair ${active ? 'navigable' : ''} ${interactive ? 'input-enabled' : ''}"
return svg`<g class="hp-stair hp-stair-view ${active ? 'navigable' : ''} ${interactive ? 'input-enabled' : ''}"
data-hp="stair" data-id=${stair.id} data-kind=${stair.kind}
data-target-state=${targetState}
style=${stairStyleVars(
+4 -1
View File
@@ -1636,6 +1636,9 @@ export const planStyles = css`
.dthandle, .dtknob, dt-* cursors) painted in the top overlay; the draft
being drawn is inert until it is placed */
.hp-stair.draft .hp-stair-hit { pointer-events: none; }
.hp-stair.input-enabled .hp-stair-hit { cursor: move; }
/* #693: the move cursor belongs to the plan editor. The View layer sets
input-enabled only to receive clicks; its hit area keeps the group's
cursor: pointer on a link, the stage's otherwise. */
.hp-stair.input-enabled:not(.hp-stair-view) .hp-stair-hit { cursor: move; }
.alignmsg { margin: 0 0 8px; font-size: 13px; line-height: 1.45; }
`;
+35 -40
View File
@@ -879,28 +879,25 @@ test('#438 поставляемый граф лежит внутри полос
assert.equal(violation, null, violation?.text);
});
test('#438 рост выше потолка — отказ с числом и с указанием, что делать', () => {
const grew = initialViewCeilingViolation(292_400, { ceiling: 292_000, band: 2_000 });
test('#438/#699 рост выше полосы над потолком беты — отказ с числом и с указанием, что делать', () => {
const grew = initialViewCeilingViolation(294_400, { ceiling: 292_000, band: 2_000 });
assert.equal(grew.kind, 'grew');
assert.equal(grew.over, 400);
assert.match(grew.text, /выше потолка 292000 B на 400 B/);
assert.equal(grew.over, 2_400);
assert.match(grew.text, /выше потолка беты 292000 B на 2400 B — больше полосы 2000 B/);
assert.match(grew.text, /Поднимите потолок в этом же коммите/);
assert.match(grew.text, /#367/, 'у отказа обязан быть выход, а не только запрет');
// Ровно на потолке — ещё не рост: граница включительная, иначе гейт краснеет
// на равенстве и разбираться идут не с графом, а с гейтом.
assert.equal(initialViewCeilingViolation(292_000, { ceiling: 292_000 }), null);
// Ровно потолок плюс полоса — ещё не рост: граница включительная.
assert.equal(initialViewCeilingViolation(294_000, { ceiling: 292_000, band: 2_000 }), null);
assert.equal(initialViewCeilingViolation(292_400, { ceiling: 292_000, band: 2_000 }), null, 'рост в полосе задачу не красит');
});
test('#438 падение ниже полосы требует опустить потолок', () => {
// Вторая половина храповика, без которой он не храповик: выигрыш, который не
// зафиксировали, отыгрывается обратно молча. Так запас бюджета ушёл с 26 КБ
// до 8.3 КБ за сутки — каждая отдельная строка выглядела нормально.
const shrank = initialViewCeilingViolation(289_500, { ceiling: 292_000, band: 2_000 });
assert.equal(shrank.kind, 'shrank');
assert.equal(shrank.under, 2_500);
assert.match(shrank.text, /Опустите потолок/);
assert.equal(initialViewCeilingViolation(290_000, { ceiling: 292_000, band: 2_000 }), null,
'нижняя граница полосы тоже включительная');
test('#699 падение ниже потолка задачу не красит — потолок опускает бета', () => {
// Вторая половина храповика не исчезла, а переехала на бету:
// `node scripts/ratchets.mjs tighten` опускает потолок до факта кандидата.
assert.equal(initialViewCeilingViolation(289_500, { ceiling: 292_000, band: 2_000 }), null);
assert.equal(initialViewCeilingViolation(100_000, { ceiling: 292_000, band: 2_000 }), null);
assert.equal(lazyGraphCeilingViolation(10, { ceiling: 239_000, label: 'lazy editor graph' }), null);
assert.equal(lazyGraphCeilingViolation(241_001, { ceiling: 239_000, label: 'lazy editor graph' }).kind, 'grew');
assert.equal(initialViewCeilingViolation(NaN).kind, 'missing');
assert.equal(initialViewCeilingViolation(undefined).kind, 'missing');
});
@@ -1030,15 +1027,15 @@ test('#438 CLI действительно применяет потолок, а
assert.equal(inside.status, 0, inside.output);
// #627 AC1: the onboarding graph is printed with its ceiling and band.
assert.match(inside.output, new RegExp(`lazy onboarding: ${LAZY_ONBOARDING_GZIP_CEILING - 1_000} B gzip`
+ ` \\(потолок ${LAZY_ONBOARDING_GZIP_CEILING} B ±${LAZY_GRAPH_CEILING_BAND}\\)`));
+ ` \\(потолок ${LAZY_ONBOARDING_GZIP_CEILING} B \\+${LAZY_GRAPH_CEILING_BAND}\\)`));
const grew = runBudgetCli(INITIAL_VIEW_GZIP_CEILING + 1);
// #699: полоса — над потолком беты; выше неё CLI называет храповик, ниже
// потолка задача не краснеет.
const grew = runBudgetCli(INITIAL_VIEW_GZIP_CEILING + INITIAL_VIEW_CEILING_BAND + 1);
assert.equal(grew.status, 1, grew.output);
assert.match(grew.output, /выше потолка/);
assert.match(grew.output, /выше потолка беты .* больше полосы/);
const shrank = runBudgetCli(INITIAL_VIEW_GZIP_CEILING - INITIAL_VIEW_CEILING_BAND - 1);
assert.equal(shrank.status, 1, shrank.output);
assert.match(shrank.output, /Опустите потолок/);
assert.equal(shrank.status, 0, shrank.output);
// И общий бюджет остаётся внешней стеной: он выше потолка, значит красным
// становится потолок, а не бюджет — но и бюджет обязан уметь падать.
@@ -1064,10 +1061,8 @@ test('#593 потолки ленивых графов — гейт, а не ст
] : []) {
const violation = lazyGraphCeilingViolation(bytes, { ceiling, label });
assert.equal(violation, null, violation?.text);
// Факт лежит не у края полосы — с тем же запасом, что у стартового графа.
assert.ok(ceiling - bytes > 500, `${label}: сверху меньше 500 Б — это шум`);
assert.ok(bytes - (ceiling - LAZY_GRAPH_CEILING_BAND) > 500,
`${label}: снизу меньше 500 Б — гейт потребует опустить потолок из-за шума`);
// #699: запас сверху — полоса над потолком беты; снизу границы у задачи нет.
assert.ok(ceiling + LAZY_GRAPH_CEILING_BAND - bytes > 500, `${label}: сверху меньше 500 Б — это шум`);
}
// Гейт обязан быть исполняемым и на синтетике, обе стороны — и это не
// зависит от свежести бандла: мутант «потолок никогда не срабатывает»
@@ -1075,13 +1070,12 @@ test('#593 потолки ленивых графов — гейт, а не ст
const grew = lazyGraphCeilingViolation(20_000, { ceiling: 17_900, label: 'lazy furniture art graph' });
assert.equal(grew.kind, 'grew');
assert.equal(grew.over, 2_100);
assert.match(grew.text, /lazy furniture art graph 20000 B gzip выше потолка 17900 B на 2100 B/);
const shrank = lazyGraphCeilingViolation(15_000, { ceiling: 17_900, label: 'lazy furniture art graph' });
assert.equal(shrank.kind, 'shrank');
assert.match(shrank.text, /Опустите потолок/);
// Границы полосы включительные — иначе гейт краснеет на равенстве.
assert.equal(lazyGraphCeilingViolation(17_900, { ceiling: 17_900, label: 'x' }), null);
assert.equal(lazyGraphCeilingViolation(15_900, { ceiling: 17_900, label: 'x' }), null);
assert.match(grew.text, /lazy furniture art graph 20000 B gzip выше потолка беты 17900 B на 2100 B/);
// #699: ниже потолка — не находка ветки, потолок опускает бета.
assert.equal(lazyGraphCeilingViolation(15_000, { ceiling: 17_900, label: 'lazy furniture art graph' }), null);
// Граница полосы включительная — иначе гейт краснеет на равенстве.
assert.equal(lazyGraphCeilingViolation(19_900, { ceiling: 17_900, label: 'x' }), null);
assert.equal(lazyGraphCeilingViolation(19_901, { ceiling: 17_900, label: 'x' }).kind, 'grew');
assert.equal(lazyGraphCeilingViolation(NaN, { ceiling: 17_900, label: 'x' }).kind, 'missing');
// Текст обязан называть граф: «graph выше потолка» не говорит, куда смотреть.
assert.match(lazyGraphCeilingViolation(NaN, { ceiling: 1, label: 'lazy editor graph' }).text,
@@ -1096,14 +1090,15 @@ test('#627 AC1 граф онбординга гейтится тем же пот
const bytes = manifest.lazyOnboardingGzipBytes;
assert.ok(Number.isFinite(bytes) && bytes > 0, 'манифест обязан измерять граф онбординга');
assert.doesNotThrow(() => assertBundleBudget(manifest, 1_000_000, undefined, ...ceilings(bytes)));
const over = bytes - LAZY_GRAPH_CEILING_BAND - 1;
assert.throws(
() => assertBundleBudget(manifest, 1_000_000, undefined, ...ceilings(bytes - 1)),
new RegExp(`lazy onboarding graph ${bytes} B gzip выше потолка ${bytes - 1} B на 1 B`),
);
assert.throws(
() => assertBundleBudget(manifest, 1_000_000, undefined, ...ceilings(bytes + LAZY_GRAPH_CEILING_BAND + 1)),
/lazy onboarding graph \d+ B gzip ниже потолка .*Опустите потолок/,
() => assertBundleBudget(manifest, 1_000_000, undefined, ...ceilings(over)),
new RegExp(`lazy onboarding graph ${bytes} B gzip выше потолка беты ${over} B на ${bytes - over} B`),
);
assert.doesNotThrow(() => assertBundleBudget(manifest, 1_000_000, undefined, ...ceilings(bytes - LAZY_GRAPH_CEILING_BAND)),
'#699: рост в полосе над потолком беты задачу не красит');
assert.doesNotThrow(() => assertBundleBudget(manifest, 1_000_000, undefined, ...ceilings(bytes + LAZY_GRAPH_CEILING_BAND + 1)),
'#699: ниже потолка — не находка ветки, потолок опускает бета');
// Потолок по умолчанию — поставляемый, и поставляемый граф в его полосе —
// пока бандл свежий (#657); синтетика выше от свежести не зависит.
if (!shippedBundleIsFresh(manifest)) { t.diagnostic(STALE_SHIPPED_BUNDLE); return; }
+1 -1
View File
@@ -215,7 +215,7 @@ test('#586: preflight спрашивает режим одним значени
assert.match(workflow, /classify-changes\.mjs --screenshots-mode/);
assert.ok(!/=\s*"heavy=true"/.test(workflow),
'сравнение со строкой «heavy=true» вернулось — строгий режим снова не включится');
assert.match(workflow, /check-docs\.mjs --external --screenshots=\$mode/);
assert.match(workflow, /check-docs\.mjs "\$external" --screenshots=\$mode/);
});
test('#510 AC1 / #601 AC1: мутанты по диффу запрашиваются только кнопкой mutants=true и PR — не пушем, не кандидатом беты, не full', () => {
+16
View File
@@ -105,3 +105,19 @@ test('the audited beta.2 baseline exception is exact and golden-only', () => {
assert.equal(validateHistoricalCommit(`${audited.slice(0, -1)}2`, message, changed).length, 2);
assert.match(validateHistoricalCommit(audited, 'Update baseline', changed)[0], /Issue/);
});
// #701 (PROCESS.md §3 п.10): трейлеры — только на коммитах с продуктовыми и
// инфраструктурными файлами; документационный коммит их не требует.
test('#701: документационный коммит (только класс C) трейлеров не требует', () => {
assert.deepEqual(validateCommitMessage('docs: fix a typo in the guide', ['docs/USER-GUIDE.md', 'README.md']), []);
assert.deepEqual(validateCommitMessage('docs: changelog wording', ['docs/CHANGELOG.md']), []);
// Хоть один файл вне класса C — прежнее правило.
assert.equal(validateCommitMessage('fix: x', ['docs/USER-GUIDE.md', 'src/card.ts']).length, 2);
assert.equal(validateCommitMessage('test: x', ['test/a.test.mjs']).length, 2);
assert.equal(validateCommitMessage('build: x', ['dist/houseplan-card.js']).length >= 2, true);
// Судить нечем — не документационный коммит.
assert.equal(validateCommitMessage('docs: typo', []).length, 2);
// Трейлер, если он есть, судится всегда: кривой номер — ошибка и в docs-коммите.
assert.equal(validateCommitMessage('docs: typo\n\nIssue: #x', ['docs/a.md']).length, 2);
assert.deepEqual(validateCommitMessage('docs: typo\n\nIssue: #9\nUser-Visible: no', ['docs/a.md']), []);
});
+30 -30
View File
@@ -14,10 +14,19 @@ import { readFileSync } from 'node:fs';
// Мера — `split('\n').length`, то есть строки плюс завершающий перевод.
// Та же функция и для потолков, и для измерения: две разные меры разошлись бы
// на единицу, и гейт краснел бы на пустом месте (проверено при написании).
const SLACK = 250;
//
// #699 (решение владельца 2026-09-28): полоса вместо точки. Потолок —
// факт на последней бете; задача может вырасти над ним не больше чем на
// CORE_BAND строк, и уменьшение её не красит. Потолки опускает до факта
// релиз-менеджер раз в бету (`node scripts/ratchets.mjs tighten`): пока их
// правила каждая задача, два параллельных ядра конфликтовали на этих числах, а
// задача упиралась в потолок, потому что перед ней влили чужую (#689 после #691).
export const CORE_BAND = 50;
// Потолки. Меняются только вручную и только вместе с объяснением в ревью:
// потолок, который вычисляется от текущего размера, потолком не является.
// С #699 число — факт последней беты: его ставит `ratchets.mjs tighten` на
// кандидате, а строки ниже — история решений, а не расчёт текущего числа.
const CAPS = {
// #485 adds the View-side subscription/render integration seams; the live
// model and rendering themselves remain in dedicated modules.
@@ -59,13 +68,14 @@ const CAPS = {
};
/**
* Храповик: наверх не пускает, вниз — требует зафиксировать выигрыш.
* Храповик с полосой (#699): выше `cap + band` не пускает; выигрыш фиксирует
* бета, а не задача.
*
* Вторая половина важнее первой. Без неё вынос двух тысяч строк ничего не
* изменит: потолок останется прежним, и через полгода ядро дорастёт до него
* обратно — молча и «в рамках бюджета».
* Вторая половина храповика не исчезла, а переехала: без неё вынос двух тысяч
* строк ничего не изменит — потолок останется прежним, и ядро дорастёт до него
* обратно молча. Поэтому `tighten` на каждой бете опускает потолок до факта.
*/
export function coreBudgetViolations(sizes, caps, slack = SLACK) {
export function coreBudgetViolations(sizes, caps, band = CORE_BAND) {
const problems = [];
for (const [file, cap] of Object.entries(caps)) {
const lines = sizes[file];
@@ -73,19 +83,13 @@ export function coreBudgetViolations(sizes, caps, slack = SLACK) {
problems.push({ file, kind: 'missing', text: `${file}: файл не измерен` });
continue;
}
if (lines > cap) {
if (lines > cap + band) {
problems.push({
file, kind: 'grew', over: lines - cap,
text: `${file}: ${lines} строк при потолке ${cap} — выросло на ${lines - cap}.`
text: `${file}: ${lines} строк при потолке беты ${cap} — выросло на ${lines - cap}, больше полосы ${band}.`
+ ' Вынесите столько же в отдельный модуль либо поднимите потолок'
+ ' отдельным решением, объяснив его в ревью.',
});
} else if (lines < cap - slack) {
problems.push({
file, kind: 'shrank', under: cap - lines,
text: `${file}: ${lines} строк при потолке ${cap} — на ${cap - lines} меньше.`
+ ' Опустите потолок: незафиксированный выигрыш ядро отыграет обратно.',
});
}
}
return problems;
@@ -93,37 +97,33 @@ export function coreBudgetViolations(sizes, caps, slack = SLACK) {
const measure = (file) => readFileSync(new URL(`../${file}`, import.meta.url), 'utf8').split('\n').length;
test('ядра не выросли выше потолка и не опустились ниже него молча', () => {
test('ядра не выросли выше потолка беты больше чем на полосу (#699)', () => {
const sizes = Object.fromEntries(Object.keys(CAPS).map((file) => [file, measure(file)]));
const problems = coreBudgetViolations(sizes, CAPS);
assert.deepEqual(problems.map((p) => p.text), [], problems.map((p) => p.text).join('\n'));
});
test('рост выше потолка становится нарушением с числом', () => {
test('рост выше полосы становится нарушением с числом', () => {
const [problem] = coreBudgetViolations({ 'a.ts': 1300 }, { 'a.ts': 1000 });
assert.equal(problem.kind, 'grew');
assert.equal(problem.over, 300);
assert.match(problem.text, /выросло на 300/);
assert.match(problem.text, /выросло на 300, больше полосы 50/);
});
test('заметное уменьшение требует опустить потолок', () => {
const [problem] = coreBudgetViolations({ 'a.ts': 700 }, { 'a.ts': 1000 });
assert.equal(problem.kind, 'shrank');
assert.equal(problem.under, 300);
assert.match(problem.text, /Опустите потолок/);
test('#699 уменьшение задачу не красит — потолок опускает бета', () => {
assert.deepEqual(coreBudgetViolations({ 'a.ts': 700 }, { 'a.ts': 1000 }), []);
assert.deepEqual(coreBudgetViolations({ 'a.ts': 1 }, { 'a.ts': 1000 }), []);
});
test('изменение в пределах люфта не трогает никого', () => {
test('#699 рост в пределах полосы задачу не красит', () => {
assert.deepEqual(coreBudgetViolations({ 'a.ts': 1000 }, { 'a.ts': 1000 }), []);
assert.deepEqual(coreBudgetViolations({ 'a.ts': 800 }, { 'a.ts': 1000 }), []);
assert.deepEqual(coreBudgetViolations({ 'a.ts': 751 }, { 'a.ts': 1000 }), []);
assert.deepEqual(coreBudgetViolations({ 'a.ts': 1049 }, { 'a.ts': 1000 }), []);
});
test('границы включительно: ровно потолок и ровно люфт нарушением не считаются', () => {
assert.deepEqual(coreBudgetViolations({ 'a.ts': 1000 }, { 'a.ts': 1000 }), []);
assert.deepEqual(coreBudgetViolations({ 'a.ts': 750 }, { 'a.ts': 1000 }), []);
assert.equal(coreBudgetViolations({ 'a.ts': 1001 }, { 'a.ts': 1000 })[0].kind, 'grew');
assert.equal(coreBudgetViolations({ 'a.ts': 749 }, { 'a.ts': 1000 })[0].kind, 'shrank');
test('#699 границы включительно: ровно потолок плюс полоса нарушением не считается', () => {
assert.equal(CORE_BAND, 50, 'полоса ядра — решение владельца 2026-09-28');
assert.deepEqual(coreBudgetViolations({ 'a.ts': 1050 }, { 'a.ts': 1000 }), []);
assert.equal(coreBudgetViolations({ 'a.ts': 1051 }, { 'a.ts': 1000 })[0].kind, 'grew');
});
test('потолки заданы для двух ядер и ни для чего больше', () => {
+7 -1
View File
@@ -30,8 +30,14 @@ test('check-docs: только две проверки свежести идут
assert.ok(viaMode[0].includes('fingerprint is stale'));
assert.ok(viaMode[1].includes('capture script changed'));
// Хеш картинки, полнота набора сцен и ссылки не имеют права ослабляться.
for (const always of ['image hash does not match manifest', 'scenario set is incomplete', 'external link returned']) {
for (const always of ['image hash does not match manifest', 'scenario set is incomplete']) {
const line = source.split('\n').find((l) => l.includes(always));
assert.ok(line && line.includes('errors.push'), `${always} остаётся ошибкой в обоих режимах`);
}
// #700: внешние ссылки не зависят от режима скриншотов; предупреждением их
// делает только явный `--external=warn` на ветке задачи.
const external = source.split('\n').find((l) => l.includes('external link returned'));
assert.ok(external && external.includes('externalErrors.push'), 'внешняя ссылка идёт в свой сток');
assert.match(source, /const externalErrors = EXTERNAL_WARN \? warnings : errors;/);
assert.doesNotMatch(source, /externalErrors = freshness/);
});
+10
View File
@@ -44,3 +44,13 @@ test('#634 entry-cost: AGENTS.md называет те же маршруты в
assert.deepEqual(route('reviewer'), ROUTES.reviewer.files);
assert.deepEqual(route('changing the pipeline'), ROUTES.canon.files);
});
test('#701 D14: промпт ревьюера читает маршрут reviewer в том же порядке, что AGENTS.md', async () => {
const { readFileSync } = await import('node:fs');
const { ROUTES } = await import('../scripts/entry-cost.mjs');
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
const start = workflow.indexOf('Прочитай в этом порядке, прежде чем судить:');
assert.ok(start > 0, 'нумерованный порядок чтения в промпте найден');
const items = [...workflow.slice(start, start + 3000).matchAll(/^\s+(\d)\. (\S+)/gm)].slice(0, 3).map((m) => m[2]);
assert.deepEqual(items, ROUTES.reviewer.files);
});
+34 -1
View File
@@ -82,7 +82,7 @@ test('каждый исход, меняющий метку, объясняетс
* dev по порядку (следующая после каждого отклонённого lease), ответы
* Validate — по порядку кандидатов.
*/
function fakeOps({ base = 'dev0', devTips = ['dev0'], validate = [], leaseRejects = 0, patchIds = {}, branchTip, material, conflictOnce = false, indexStale = false }) {
function fakeOps({ base = 'dev0', devTips = ['dev0'], validate = [], leaseRejects = 0, patchIds = {}, branchTip, material, conflictOnce = false, indexStale = false, deleteOk = true }) {
const calls = [];
let devIndex = 0;
let validateIndex = 0;
@@ -123,6 +123,7 @@ function fakeOps({ base = 'dev0', devTips = ['dev0'], validate = [], leaseReject
return { result, url: `https://run/${sha}` };
},
comment: (issue, body) => { calls.push(['comment', body.split('\n')[0]]); },
deleteBranch: (ref, expected) => { calls.push(['delete', ref, expected]); return deleteOk; },
log: () => {},
};
}
@@ -642,3 +643,35 @@ test('#696: трек ask по-прежнему диспатчит Validate с м
['dispatch', 'issue/1-x'], ['validate', 'cand-mat-on-dev1', 'workflow_dispatch'],
]);
});
test('#702: после fast-forward ветка задачи удаляется с lease на влитую вершину', async () => {
const ops = fakeOps({ devTips: ['dev0'], branchTip: 'mat', material: 'mat', indexStale: true });
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
assert.equal(r.merged, true);
assert.deepEqual(ops.calls.filter((c) => c[0] === 'delete'), [['delete', 'issue/1-x', 'mat']],
'вершина ветки — материал: индекс-коммит живёт только в dev');
assert.match(ops.calls.find((c) => c[0] === 'comment')[1], /ветка `issue\/1-x` удалена/);
});
test('#702: после слияния кандидата удаляется ветка с его вершиной; неудачное слияние ветку не трогает', async () => {
const ops = fakeOps({ base: 'dev0', devTips: ['dev1'], branchTip: 'mat', material: 'mat' });
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
assert.equal(r.action, 'push');
assert.deepEqual(ops.calls.filter((c) => c[0] === 'delete'), [['delete', 'issue/1-x', 'cand-mat-on-dev1']]);
const red = fakeOps({ base: 'dev0', devTips: ['dev1'], branchTip: 'mat', material: 'mat', validate: ['failed'] });
await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops: red });
assert.deepEqual(red.calls.filter((c) => c[0] === 'delete'), [], 'красный Validate — ветка остаётся автору');
const stale = fakeOps({ devTips: ['dev0'], branchTip: 'other', material: 'mat' });
stale.revParse = (ref) => (ref === 'origin/dev' ? 'dev0' : ref.startsWith('origin/issue') ? 'other' : ref.endsWith('^') ? 'foreign' : ref);
const rejected = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops: stale });
assert.equal(rejected.action, 'reject-stale');
assert.deepEqual(stale.calls.filter((c) => c[0] === 'delete'), [], '#312 — ветку не трогаем');
});
test('#702: сдвинутая вершина — ветка остаётся, слияние в силе', async () => {
const ops = fakeOps({ devTips: ['dev0'], branchTip: 'mat', material: 'mat', deleteOk: false });
const r = await mergeCandidate({ branch: 'issue/1-x', material: 'mat', issue: 1, ops });
assert.equal(r.merged, true);
assert.match(ops.calls.find((c) => c[0] === 'comment')[1], /оставлена: её вершина сдвинулась после слияния/);
});
+14 -9
View File
@@ -9,7 +9,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
BASELINE_FILE, BUNDLE_BYTES_BAND, CARD_FILE, METRIC_NAMES, RUNTIME_FILE, bundleBytes, classifyUnused, collectMetrics, compareWithBaseline, countDelegates,
BASELINE_FILE, BUNDLE_BYTES_BAND, CARD_FILE, METRIC_BANDS, METRIC_NAMES, RUNTIME_FILE, bundleBytes, classifyUnused, collectMetrics, compareWithBaseline, countDelegates,
harnessMemberNames, hostReferences, portMemberNames, readBaseline,
} from '../scripts/monolith-metrics.mjs';
import { baselineFrom, decide } from '../scripts/unused-locals-gate.mjs';
@@ -109,15 +109,19 @@ test('#624 байты dist — сумма всех файлов, включая
}
});
test('#624 храповик: рост на 1 любого числа — красный, снижение без записи базы — тоже, равенство — зелёный', () => {
test('#699 храповик: рост сверх полосы любого числа — красный, в полосе и снижение — зелёные', () => {
const base = { delegates: 10, portMembers: 20, hostRefs: 30, portPrivates: 4, harnessPrivates: 5, bundleBytes: 1000 };
assert.deepEqual(compareWithBaseline({ ...base }, base), { grown: [], shrunk: [] });
assert.deepEqual(METRIC_BANDS, { delegates: 5, portMembers: 5, hostRefs: 25, portPrivates: 5, harnessPrivates: 5, bundleBytes: BUNDLE_BYTES_BAND });
for (const name of METRIC_NAMES.filter((n) => n !== 'bundleBytes')) {
const grown = compareWithBaseline({ ...base, [name]: base[name] + 1 }, base).grown;
assert.deepEqual(grown.map((g) => g.name), [name], `${name} +1 виден как рост`);
const decision = decide({ metrics: { ...base, [name]: base[name] + 1 }, violations: [], baseline: base });
assert.equal(decision.fail, true, `${name} +1 — красный гейт`);
const band = METRIC_BANDS[name];
assert.deepEqual(compareWithBaseline({ ...base, [name]: base[name] + band }, base).grown, [], `${name} +${band} — в полосе`);
const grown = compareWithBaseline({ ...base, [name]: base[name] + band + 1 }, base).grown;
assert.deepEqual(grown.map((g) => g.name), [name], `${name} сверх полосы виден как рост`);
const decision = decide({ metrics: { ...base, [name]: base[name] + band + 1 }, violations: [], baseline: base });
assert.equal(decision.fail, true, `${name} сверх полосы — красный гейт`);
assert.match(decision.lines.join('\n'), new RegExp(`связность выросла: ${name} `));
assert.equal(decide({ metrics: { ...base, [name]: base[name] + band }, violations: [], baseline: base }).fail, false);
}
// bundleBytes — с полосой, как gzip-потолок #438: чужой коммит в dev меняет
// dist на сотни байт, и точное число красило бы каждую ветку после ребейза.
@@ -125,9 +129,10 @@ test('#624 храповик: рост на 1 любого числа — кра
assert.deepEqual(compareWithBaseline({ ...base, bundleBytes: base.bundleBytes + BUNDLE_BYTES_BAND + 1 }, base).grown.map((g) => g.name), ['bundleBytes']);
assert.deepEqual(compareWithBaseline({ ...base, bundleBytes: base.bundleBytes - BUNDLE_BYTES_BAND - 1 }, base).shrunk.map((g) => g.name), ['bundleBytes']);
assert.equal(decide({ metrics: { ...base, bundleBytes: base.bundleBytes + BUNDLE_BYTES_BAND + 1 }, violations: [], baseline: base }).fail, true);
// #699: снижение задачу не красит — базу опускает бета.
const shrunk = decide({ metrics: { ...base, delegates: 9 }, violations: [], baseline: base });
assert.equal(shrunk.fail, true);
assert.match(shrunk.lines.join('\n'), /база не опущена: delegates 10 → 9/);
assert.equal(shrunk.fail, false);
assert.match(shrunk.lines.join('\n'), /ниже базы: delegates 10 → 9 — базу опустит бета/);
assert.equal(decide({ metrics: base, violations: [], baseline: base }).fail, false);
// Отсутствие числа в базе — рост (нельзя обнулить метрику, удалив ключ).
const { portPrivates, ...withoutOne } = base;
@@ -158,6 +163,6 @@ test('#624 живое дерево: база равна текущим числ
// фактический свежий dist отдельно проверяют bundle-budget и lint:unused.
const source = (list) => list.filter((s) => s.name !== 'bundleBytes');
assert.deepEqual(source(grown), [], 'связность выросла — вернуть или обосновать');
assert.deepEqual(source(shrunk), [], 'связность упала — опустить базу: node scripts/unused-locals-gate.mjs --update');
void shrunk; // #699: ниже базы — не ошибка ветки, базу опускает бета (ratchets.mjs tighten)
assert.equal(typeof readFileSync(join(root, BASELINE_FILE), 'utf8'), 'string');
});
+5 -2
View File
@@ -206,12 +206,15 @@ test('#332: каждый гвард реестра классифицирует
assert.ok(rest >= 150, `небраузерных гвардов подозрительно мало: ${rest}`);
});
test('#659: browser guard inventory is reviewed, capped and exact', () => {
test('#659/#699: browser guard inventory is reviewed and exact; the limit is a guideline', () => {
const markdown = readFileSync(join(repoRoot, 'docs/testing-notes/mutation-browser-guards.md'), 'utf8');
const policy = browserGuardPolicy(MUTANTS, documentedBrowserGuards(markdown));
assert.equal(policy.count, BROWSER_GUARD_LIMIT);
assert.ok(policy.count > 0);
assert.deepEqual(policy.missingReasons, []);
assert.deepEqual(policy.staleReasons, []);
assert.equal(BROWSER_GUARD_LIMIT, 200, 'ориентир остаётся числом в выводе --check');
const over = browserGuardPolicy([...MUTANTS, ...Array.from({ length: BROWSER_GUARD_LIMIT + 1 }, (_, i) => ({ id: `x-${i}`, guard: 'node demo/smoke_x.mjs' }))], new Set());
assert.equal(over.overLimit, true, 'сверх ориентира policy это видит — --check предупреждает');
});
test('#659: browser-only mutations reuse one clean bundle unless their patch is bundled', () => {
+1
View File
@@ -134,6 +134,7 @@ const HOOK_FILES = [
'scripts/pre-push-gate.mjs',
'scripts/branch-state.mjs',
'scripts/process-gate.mjs',
'scripts/change-classes.mjs', // #701: классы изменений — общие для гейта и трейлеров
'scripts/validate-commit-provenance.mjs',
'scripts/bundle-policy.mjs', // #657: правило бандла в проверке происхождения
'scripts/bundle-tree.mjs',
+1 -1
View File
@@ -50,7 +50,7 @@ const KEY_RULES = {
['5-треки-ship-show-ask--метка-владельца', 'ожидаемое поведение уже зафиксировано'],
['71-цепочка', 'Владельцу задаются только продуктовые вопросы'],
['71-цепочка', 'issue остаётся в `S3-spec` и получает `blocked`'],
['26-в-разработке--реализация', 'каждый коммит несёт трейлеры `Issue: #<NN>` и `User-Visible: yes|no`'],
['26-в-разработке--реализация', 'каждый коммит с файлами классов A, B или D несёт трейлеры `Issue: #<NN>` и `User-Visible: yes|no`'],
['3-правила', '`User-Visible: yes` требует правок в обоих changelog в том же коммите'],
['26-в-разработке--реализация', 'шесть классов риска'],
['26-в-разработке--реализация', 'Скоуп не расширяется'],
+75
View File
@@ -0,0 +1,75 @@
// #699, PROCESS.md §8: полоса над потолком беты вместо точки; потолки
// опускает до факта релиз-менеджер одной командой на кандидате.
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
BUNDLE_CEILINGS, CORE_BAND, CORE_BUDGET_FILE, BUNDLE_BUDGET_FILE, formatRow, ratchetRows, ratchetState,
readConst, readCoreCaps, rewriteConst, rewriteCoreCaps,
} from '../scripts/ratchets.mjs';
import { METRIC_NAMES } from '../scripts/monolith-metrics.mjs';
const read = (path) => readFileSync(fileURLToPath(new URL(`../${path}`, import.meta.url)), 'utf8');
test('#699 состояние храповика: равен, рыхлый, в полосе, выше полосы, не измерен', () => {
assert.equal(ratchetState(100, 100, 50), 'tight');
assert.equal(ratchetState(90, 100, 50), 'loose');
assert.equal(ratchetState(150, 100, 50), 'over', 'граница полосы включительная — как у гейтов ветки');
assert.equal(ratchetState(151, 100, 50), 'grew');
assert.equal(ratchetState(undefined, 100, 50), 'missing');
assert.equal(ratchetState(100, null, 50), 'missing');
});
test('#699 потолки ядер читаются и переписываются в исходнике теста, комментарии на месте', () => {
const source = read(CORE_BUDGET_FILE);
const caps = readCoreCaps(source);
// Имена ядер здесь не пишутся: тесты, читающие монолит как текст, заморожены (#624).
const [first, second] = Object.keys(caps);
assert.equal(Object.keys(caps).length, 2, 'два ядра');
const next = rewriteCoreCaps(source, { [first]: 12000 });
assert.equal(readCoreCaps(next)[first], 12000);
assert.equal(readCoreCaps(next)[second], caps[second]);
assert.equal(next.split('\n').length, source.split('\n').length, 'меняется только число');
assert.match(next, /2026-09-27, #676/, 'история решений остаётся');
assert.match(source, new RegExp(`^export const CORE_BAND = ${CORE_BAND};$`, 'm'), 'полоса теста и инструмента — одно число');
});
test('#699 потолки графов читаются и переписываются с разрядами', () => {
const source = read(BUNDLE_BUDGET_FILE);
for (const entry of BUNDLE_CEILINGS) assert.ok(readConst(source, entry.name) > 0, entry.name);
const next = rewriteConst(source, 'INITIAL_VIEW_GZIP_CEILING', 300323);
assert.equal(readConst(next, 'INITIAL_VIEW_GZIP_CEILING'), 300323);
assert.match(next, /^export const INITIAL_VIEW_GZIP_CEILING = 300_323;$/m);
assert.throws(() => readConst(source, 'NO_SUCH_CEILING'), /нет export const NO_SUCH_CEILING/);
});
test('#699 отчёт видит все три вида храповиков и называет, что делать', () => {
const rows = ratchetRows({
coreCaps: { 'a.ts': 100 }, coreFacts: { 'a.ts': 90 },
bundleCeilings: Object.fromEntries(BUNDLE_CEILINGS.map((e) => [e.name, 1000])),
bundleFacts: Object.fromEntries(BUNDLE_CEILINGS.map((e) => [e.metric, 1000])),
metrics: Object.fromEntries(METRIC_NAMES.map((n) => [n, 10])),
baseline: Object.fromEntries(METRIC_NAMES.map((n) => [n, n === 'hostRefs' ? 5 : 10])),
});
assert.equal(rows.length, 1 + BUNDLE_CEILINGS.length + METRIC_NAMES.length);
assert.equal(rows.find((r) => r.kind === 'core').state, 'loose');
assert.ok(rows.filter((r) => r.kind === 'bundle').every((r) => r.state === 'tight'));
assert.equal(rows.find((r) => r.name === 'hostRefs').state, 'over');
assert.match(formatRow(rows.find((r) => r.kind === 'core')), /факт 90 · потолок 100 \(-10, полоса \+50\) — рыхлый/);
});
test('#699 публикация беты напоминает о храповиках, но не останавливается на них', () => {
const local = read('scripts/release-prerelease.mjs');
const main = local.slice(local.indexOf('const main = async'));
assert.match(main, /'scripts\/ratchets\.mjs', 'report', '--warn'/);
assert.match(main, /'scripts\/ratchets\.mjs', 'report', '--warn'\], \{ allowFailure: true, inherit: true \}\)/);
});
test('#699 r1 M1: runbook беты опускает храповики при подготовке кандидата, до публикации', () => {
const runbook = read('docs/DEVELOPMENT.md');
const prepare = runbook.slice(runbook.indexOf('Prepare the candidate as usual'), runbook.indexOf('npm run release:prerelease --'));
assert.ok(prepare.length > 0, 'раздел подготовки кандидата найден');
assert.match(prepare, /`npm run bundle:release`[\s\S]*`node scripts\/ratchets\.mjs tighten`/, 'tighten — после свежего dist/');
assert.match(prepare, /commit them with the candidate/);
});
+74 -3
View File
@@ -6,7 +6,8 @@ import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { REVIEWS_INDEX_PATH, planStop, rebaseRegenerating } from '../scripts/rebase-generated.mjs';
import { REVIEWS_INDEX_PATH, UPSTREAM_WINS, planStop, rebaseRegenerating } from '../scripts/rebase-generated.mjs';
import { PATCH_ID_EXCLUDES } from '../scripts/merge-candidate.mjs';
import { buildIndex } from '../scripts/reviews-index.mjs';
// #643: doc-коммит ветки задачи конфликтует с dev только в генерируемом
@@ -106,7 +107,7 @@ function scenario({ shared = false, tools = false } = {}) {
test('#643 planStop: разрешается только набор, где ВСЕ конфликты — индекс или объявленные вызывающим', () => {
assert.deepEqual(planStop([REVIEWS_INDEX_PATH, '', ` ${REVIEWS_INDEX_PATH}`]),
{ action: 'resolve', index: true, extra: [], conflicts: [REVIEWS_INDEX_PATH] });
{ action: 'resolve', index: true, upstream: [], extra: [], conflicts: [REVIEWS_INDEX_PATH] });
const mixed = planStop([REVIEWS_INDEX_PATH, 'src/x.ts']);
assert.equal(mixed.action, 'abort');
assert.equal(mixed.reason, 'manual');
@@ -116,7 +117,7 @@ test('#643 planStop: разрешается только набор, где ВС
assert.equal(planStop(['docs/reviews/sub/INDEX.md']).action, 'abort', 'другой INDEX.md — не индекс ревью');
assert.deepEqual(planStop([]), { action: 'abort', reason: 'no-conflicts', manual: [], conflicts: [] });
const bundle = planStop(['dist/a.js', REVIEWS_INDEX_PATH], { extra: (p) => p.startsWith('dist/') });
assert.deepEqual(bundle, { action: 'resolve', index: true, extra: ['dist/a.js'], conflicts: ['dist/a.js', REVIEWS_INDEX_PATH] });
assert.deepEqual(bundle, { action: 'resolve', index: true, upstream: [], extra: ['dist/a.js'], conflicts: ['dist/a.js', REVIEWS_INDEX_PATH] });
});
test('#643 AC1: конфликт только в INDEX.md — ребейз проходит, индекс равен пересборке каталога', () => {
@@ -293,3 +294,73 @@ test('#643 process.yml на настоящем bash: сбой помощника
assert.match(r.stdout, /::error::помощник ребейза упал/);
} finally { rmSync(root, { recursive: true, force: true }); }
});
// ---------- #698: ченджлог объединяется, база метрик берётся из dev ----------
function sharedFilesScenario({ baselineConflict = false, alsoCode = false } = {}) {
const root = mkdtempSync(join(tmpdir(), 'hp-rebase-698-'));
const work = join(root, 'work');
mkdirSync(join(work, 'docs'), { recursive: true });
mkdirSync(join(work, 'scripts'), { recursive: true });
git(root, 'init', '-q', '-b', 'dev', work);
copyFileSync(fileURLToPath(new URL('../.gitattributes', import.meta.url)), join(work, '.gitattributes'));
const log = (lines) => writeFileSync(join(work, 'docs', 'CHANGELOG.md'), `# Changelog\n\n## Unreleased\n\n${lines.join('\n')}\n\n## 1.0.0\n\n- first\n`);
const baseline = (hostRefs) => writeFileSync(join(work, 'scripts', 'monolith-baseline.json'), `${JSON.stringify({ delegates: 1, hostRefs }, null, 2)}\n`);
log([]); baseline(100);
writeFileSync(join(work, 'a.mjs'), 'export const a = 1;\n');
commitAll(work, 'base');
git(work, 'checkout', '-q', '-b', 'issue/9-x');
log(['- task nine']);
if (baselineConflict) baseline(95);
if (alsoCode) writeFileSync(join(work, 'a.mjs'), 'export const a = 9;\n');
commitAll(work, 'task');
git(work, 'checkout', '-q', 'dev');
log(['- task eight']);
if (baselineConflict) baseline(103);
if (alsoCode) writeFileSync(join(work, 'a.mjs'), 'export const a = 8;\n');
commitAll(work, 'neighbour');
git(work, 'checkout', '-q', 'issue/9-x');
return { root, work };
}
test('#698: записи ченджлога двух задач объединяются при ребейзе, конфликта нет', (t) => {
const { root, work } = sharedFilesScenario();
t.after(() => rmSync(root, { recursive: true, force: true }));
const result = rebaseRegenerating({ onto: 'dev', cwd: work, env: ENV });
assert.equal(result.ok, true, JSON.stringify(result));
assert.equal(result.stops, 0, 'union — это не остановка ребейза');
const text = readFileSync(join(work, 'docs', 'CHANGELOG.md'), 'utf8');
assert.match(text, /- task eight\n- task nine\n/, 'обе записи в Unreleased, сторона dev первой');
assert.doesNotMatch(text, /^(<<<<<<<|=======|>>>>>>>)/m);
});
test('#698: конфликт в базе метрик монолита решается в пользу dev', (t) => {
const { root, work } = sharedFilesScenario({ baselineConflict: true });
t.after(() => rmSync(root, { recursive: true, force: true }));
const result = rebaseRegenerating({ onto: 'dev', cwd: work, env: ENV });
assert.equal(result.ok, true, JSON.stringify(result));
assert.deepEqual(result.resolved, ['scripts/monolith-baseline.json ← dev']);
assert.equal(JSON.parse(readFileSync(join(work, 'scripts', 'monolith-baseline.json'), 'utf8')).hostRefs, 103);
assert.match(readFileSync(join(work, 'docs', 'CHANGELOG.md'), 'utf8'), /- task nine/);
assert.deepEqual(UPSTREAM_WINS, ['scripts/monolith-baseline.json']);
});
test('#698: база метрик вместе с конфликтом в коде — прежний отказ с перечнем', (t) => {
const { root, work } = sharedFilesScenario({ baselineConflict: true, alsoCode: true });
t.after(() => rmSync(root, { recursive: true, force: true }));
const before = git(work, 'rev-parse', 'HEAD');
const result = rebaseRegenerating({ onto: 'dev', cwd: work, env: ENV });
assert.equal(result.ok, false);
assert.deepEqual(result.manual, ['a.mjs']);
assert.deepEqual(result.conflicts, ['a.mjs', 'scripts/monolith-baseline.json']);
assert.equal(git(work, 'rev-parse', 'HEAD'), before, 'ребейз отменён, HEAD как был');
});
test('#698: patch-id кандидата не видит того, что ребейз сливает сам', () => {
for (const path of ['docs/reviews', 'docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md', 'scripts/monolith-baseline.json']) {
assert.ok(PATCH_ID_EXCLUDES.includes(`:!${path}`), path);
}
const attrs = readFileSync(fileURLToPath(new URL('../.gitattributes', import.meta.url)), 'utf8');
assert.match(attrs, /^docs\/CHANGELOG\.md merge=union$/m);
assert.match(attrs, /^docs\/CHANGELOG\.ru\.md merge=union$/m);
});
+28 -2
View File
@@ -1,10 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { selectSmokes, parseDiff, symbolTable } from '../scripts/smoke-select.mjs';
import { selectSmokes, parseDiff, symbolTable, VISUAL_MINIMUM } from '../scripts/smoke-select.mjs';
import { smokesToRun } from '../scripts/gate-small.mjs';
import { SMOKE_LINKS, registeredSmokes } from '../scripts/smoke-links.mjs';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
@@ -75,6 +77,30 @@ test('связь не доказана — это неопределённост
'символ без смока обязан быть назван');
});
test('#690 п.1′: неопределённость выдаёт визуальный минимум, доказанная связь и docs — нет', () => {
const unproven = selectSmokes(fixture('unproven.diff'), { root: repoRoot });
assert.deepEqual(unproven.visualMinimum, [...VISUAL_MINIMUM]);
for (const smoke of VISUAL_MINIMUM) {
assert.ok(smokesToRun(unproven).includes(smoke), `gate:small -- --smokes не гоняет ${smoke}`);
}
assert.deepEqual(selectSmokes(fixture('234-chain-thickness.diff'), { root: repoRoot }).visualMinimum, [],
'при доказанной связи минимум не нужен — выборка остаётся меньше матрицы');
const docs = selectSmokes(fixture('docs-only.diff'), { root: repoRoot });
assert.deepEqual(docs.visualMinimum, []);
assert.deepEqual(smokesToRun(docs), [], 'без исполняемого диффа смоков нет');
});
test('#690 п.1′: визуальный минимум — 5–8 существующих смоков, и CLI его печатает', () => {
assert.ok(VISUAL_MINIMUM.length >= 5 && VISUAL_MINIMUM.length <= 8, `в минимуме ${VISUAL_MINIMUM.length}`);
for (const smoke of VISUAL_MINIMUM) assert.ok(existsSync(join(repoRoot, 'demo', smoke)), `нет demo/${smoke}`);
assert.ok(VISUAL_MINIMUM.includes('smoke_modes.mjs'), 'смок, которого не хватило #687');
const cli = spawnSync(process.execPath, ['scripts/smoke-select.mjs', '--diff', 'test/fixtures/smoke-select/unproven.diff'],
{ cwd: repoRoot, encoding: 'utf8' });
assert.equal(cli.status, 0, cli.stderr);
assert.match(cli.stdout, /Визуальный минимум \(\d\) — прогнать до S7/);
for (const smoke of VISUAL_MINIMUM) assert.ok(cli.stdout.includes(`demo/${smoke}`), smoke);
});
test('таблица символов не берёт одиночные английские слова (#241)', () => {
const table = symbolTable(repoRoot);
for (const noise of ['floor', 'value', 'index', 'return', 'length', 'edit']) {
+40
View File
@@ -357,3 +357,43 @@ test('#669 AC2 a maximum stair collection keeps the room area and passes a bound
const actual = geometryAreaMinusStairs(room, stairs);
assert.ok(Math.abs(actual - expected) <= Math.max(expected, 1) * 1e-9, `${actual} vs ${expected}`);
});
// #693: в View над лестницей был курсор `move` — редакторское правило для
// `.hp-stair-hit` задевало и слой View, который ставит `input-enabled` только
// ради попадания. Браузерное доказательство — demo/smoke_stairs.mjs; здесь
// каскад закреплён без Chromium.
test('#693 курсор move над телом лестницы — только в редакторе плана', async () => {
const { planStyles } = await import('../test-build/styles.js');
const css = planStyles.cssText.replace(/\/\*[\s\S]*?\*\//g, '');
const rules = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)]
.map(([, selectors, body]) => [selectors.trim().split(/\s*,\s*/), body]);
const cursorOf = (body) => /(?:^|;)\s*cursor\s*:\s*([^;]+)/.exec(body)?.[1].trim() ?? null;
// Селектор вида «<составной селектор группы> .hp-stair-hit»; иная форма —
// повод расширить тест, а не молча её пропустить.
const matchesGroup = (compound, classes) => {
const parts = compound.match(/:not\(\.[\w-]+\)|\.[\w-]+|[^.:]+|:[\w-]+/g) ?? [];
return parts.every((part) => {
if (part.startsWith(':not(.')) return !classes.includes(part.slice(6, -1));
if (part.startsWith('.')) return classes.includes(part.slice(1));
throw new Error(`#693: неразобранная часть селектора «${part}» в «${compound}»`);
});
};
const hitCursor = (classes) => rules
.flatMap(([selectors, body]) => selectors
.filter((selector) => /\s\.hp-stair-hit$/.test(selector) && cursorOf(body))
.map((selector) => [selector.replace(/\s+\.hp-stair-hit$/, '').trim(), cursorOf(body)]))
.filter(([compound]) => {
assert.ok(!/\s/.test(compound), `#693: предок с потомком в «${compound}» — расширить тест`);
return matchesGroup(compound, classes);
})
.map(([, cursor]) => cursor);
assert.deepEqual(hitCursor(['hp-stair', 'input-enabled']), ['move'], 'редактор плана тащит лестницу за тело');
assert.deepEqual(hitCursor(['hp-stair', 'hp-stair-view', 'navigable', 'input-enabled']), [],
'в View у области попадания своего курсора нет — виден pointer ссылки');
assert.deepEqual(hitCursor(['hp-stair', 'hp-stair-view', 'input-enabled']), [],
'лестница без цели в View — курсор сцены, не move');
assert.ok(rules.some(([selectors, body]) => selectors.includes('.hp-stair.navigable') && cursorOf(body) === 'pointer'),
'ссылка несёт pointer на группе');
const view = readFileSync(new URL('../src/stairs-view.ts', import.meta.url), 'utf8');
assert.match(view, /<g class="hp-stair hp-stair-view /, 'слой View помечает свои лестницы');
});
+56
View File
@@ -0,0 +1,56 @@
// #706: исход rereview возвращает задачу в ту же метку `S7-code-review`, и
// одним вызовом `gh` она добавлялась и тут же снималась — задача оставалась без
// статуса, новый заход не стартовал.
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { moveStatusLabel } from '../scripts/status-label.mjs';
const REPO = 'Matysh/houseplan-card';
const recorder = (fail = () => false) => {
const calls = [];
const execute = (args, opts = {}) => {
calls.push(args.slice(5).join(' '));
const status = fail(args, calls.length) ? 1 : 0;
if (status && !opts.allowFailure) throw new Error(`gh ${args.join(' ')} → boom`);
return { status, stderr: status ? 'boom' : '' };
};
return { calls, execute };
};
test('#706 rereview: та же метка снимается и ставится заново отдельными вызовами', () => {
const { calls, execute } = recorder();
const how = moveStatusLabel({ repo: REPO, issue: 699, from: 'S7-code-review', to: 'S7-code-review', execute });
assert.equal(how, 'relabeled');
assert.deepEqual(calls, ['--remove-label S7-code-review', '--add-label S7-code-review'],
'снятие, затем постановка — событие labeled запускает новый заход');
});
test('#706 обычный исход — одна правка: новая метка ставится, прежняя снимается', () => {
const { calls, execute } = recorder();
assert.equal(moveStatusLabel({ repo: REPO, issue: 700, from: 'S7-code-review', to: 'S8-merged', execute }), 'moved');
assert.deepEqual(calls, ['--add-label S8-merged --remove-label S7-code-review']);
const none = recorder();
moveStatusLabel({ repo: REPO, issue: 700, from: '', to: 'S6-in-progress', execute: none.execute });
assert.deepEqual(none.calls, ['--add-label S6-in-progress'], 'без исходной метки снимать нечего');
});
test('#706 сбой повторной постановки роняет шаг, а не оставляет задачу без статуса молча', () => {
const once = recorder((args, n) => n === 2);
moveStatusLabel({ repo: REPO, issue: 699, from: 'S7-code-review', to: 'S7-code-review', execute: once.execute });
assert.deepEqual(once.calls, ['--remove-label S7-code-review', '--add-label S7-code-review', '--add-label S7-code-review'],
'одна попытка восстановления, как в автосверке (#555)');
const always = recorder((args) => args.includes('--add-label'));
assert.throws(() => moveStatusLabel({ repo: REPO, issue: 699, from: 'S7-code-review', to: 'S7-code-review', execute: always.execute }),
/could not restore S7-code-review/);
assert.throws(() => moveStatusLabel({ repo: REPO, issue: 699, from: 'S7-code-review', to: '', execute: always.execute }), /usage/);
});
test('#706 шаг конвейера переставляет метку через скрипт, а не одним вызовом gh', () => {
const workflow = readFileSync(fileURLToPath(new URL('../.github/workflows/_process.yml', import.meta.url)), 'utf8');
const step = workflow.slice(workflow.indexOf('- name: Переставить метку'), workflow.indexOf('- name: Сводка длительности стадий'));
assert.ok(step.length > 0, 'шаг найден');
assert.match(step, /node scripts\/status-label\.mjs --repo="\$\{\{ github\.repository \}\}" \\\n\s+--issue="\$NUM" --from="\$FROM" --to="\$TO"/);
assert.doesNotMatch(step, /gh issue edit/, 'совмещённый вызов снимал ту же метку, которую ставил');
});
+1 -1
View File
@@ -198,7 +198,7 @@ test('#632 r1: trivial issue in S6/S7 keeps class A rights without any spec arte
assert.ok(packet.rights.every((l) => !l.includes('файлы класса A трогать НЕЛЬЗЯ')), labels.join(','));
}
assert.deepEqual(productFlowEvidence({ status: 'S6-in-progress', labels: ['trivial'], issue: { body } }),
['короткий трек trivial (ТЗ не пишется, §5.1)']);
['прежняя метка trivial — продуктовый поток, читается как track:show (§5.1)']);
assert.deepEqual(productFlowEvidence({ status: 'S6-in-progress', labels: ['small', 'infra'], issue: { body } }), [],
'только trivial: small несёт ТЗ в теле и доказывается разделом «## ТЗ»');
});
+28 -1
View File
@@ -440,7 +440,7 @@ test('смоки, golden и performance_smoke условны по heavy (#479)',
assert.match(text, /classify-changes\.mjs --heavy/);
assert.match(text, /workflow_dispatch:\n\s+inputs:\n\s+full:/);
// preflight: режим скриншотов считает тот же скрипт.
assert.match(text, /check-docs\.mjs --external --screenshots=\$mode/);
assert.match(text, /check-docs\.mjs "\$external" --screenshots=\$mode/);
});
test('ночной прогон — dispatch Validate на dev с full=true (#479)', () => {
@@ -613,3 +613,30 @@ test('#541: Validate всегда публикует proof точной попы
assert.equal(reuse.includes('lookup-only: true'), false,
'marker contents must be restored and verified, not reduced to a cache-hit bit');
});
test('#700: на ветке задачи зеркало workflow и внешние ссылки — предупреждение, на dev — красный и issue', () => {
const workflow = read('validate.yml');
const preflight = workflow.slice(workflow.indexOf('\n preflight:\n'), workflow.indexOf('\n changes:\n'));
const docs = preflight.slice(preflight.indexOf('id: docs'), preflight.indexOf('id: reviews_index'));
assert.match(docs, /case "\$REF" in refs\/heads\/issue\/\*\) external=--external=warn ;; esac/);
assert.match(docs, /REF: \$\{\{ github\.ref \}\}/);
const verdict = preflight.slice(preflight.indexOf('- name: Вердикт предполётных проверок'));
assert.match(verdict, /case "\$REF" in refs\/heads\/issue\/\*\) task_branch=true ;; esac/);
assert.match(verdict, /if \[ "\$task_branch" = "true" \]; then\n\s+advise "тонкие вызывающие workflow в main и dev" "\$WORKFLOW_SYNC"\n\s+else\n\s+check "тонкие вызывающие workflow в main и dev"/);
assert.doesNotMatch(verdict.slice(verdict.indexOf('advise() {'), verdict.indexOf('task_branch=false')), /fail=1/,
'предупреждение не красит вердикт');
const issue = preflight.slice(preflight.indexOf('- name: "Расхождение зеркала на dev — issue владельцу"'));
assert.match(issue, /if: github\.event_name == 'push' && github\.ref == 'refs\/heads\/dev' && steps\.workflow_sync\.outcome == 'failure'/);
assert.match(issue, /gh issue list --repo "\$REPO" --state open --search/, 'одно issue, а не одно на каждый push');
// r1 #700: несчитанный список — не повод заводить новое.
assert.match(issue, /if ! existing=\$\(gh issue list [\s\S]*?\); then\n\s+echo "::warning::[^"]*"\n\s+exit 0\n\s+fi/);
assert.doesNotMatch(issue.slice(0, issue.indexOf('gh issue create')), /\|\| true\)/, 'сбой чтения не глушится в пустой ответ');
assert.match(preflight, /permissions:\n\s+contents: read\n\s+actions: read\n\s+issues: write/);
});
test('#700: check-docs --external=warn сводит внешние отказы в предупреждения', () => {
const source = read('../../scripts/check-docs.mjs');
assert.match(source, /const EXTERNAL_WARN = process\.argv\.includes\('--external=warn'\);/);
assert.match(source, /const externalErrors = EXTERNAL_WARN \? warnings : errors;/);
assert.equal((source.match(/externalErrors\.push\(/g) || []).length, 2, 'оба вида внешнего отказа');
});