Гард «uncaught exception внутри карточки» жил в demo/serve.mjs с 2026-07-27 и
не срабатывал ни разу в самом частом случае. Счётчик читался синхронно, а
Playwright доставляет pageerror асинхронно по CDP: если исключение возникло
после последнего обращения смока к странице, счётчик к моменту проверки
нулевой, а browser.close() уносит недоставленное событие. В логе это видно
дословно — EXC печатается после результата и до OK.
finish() теперь делает round-trip по открытым страницам перед чтением
счётчика. Страницы регистрируются там, где создаются: ссылок на них у
finish(browser, out) нет, а менять сигнатуру нельзя — так её зовут 205
смоков.
Medium-1 жёлтого ревью ТЗ закрыт расширением, а не оговоркой. Страницы,
созданные смоком после launch(), регистрация в launchInternal не покрывает:
smoke_zoom_flash печатал своё EXC2 мимо счётчика, три страницы
smoke_svg_sandbox не имели слушателя вовсе. Документировать слепую зону в
задаче, которая существует ради устранения слепой зоны, значит закрыть issue,
оставив дефект. Наружу отдана одна функция watchPage(page): подписка и
регистрация неразделимы, иначе появится страница, чьи исключения считаются, а
доставки не ждёт никто.
Разрыв оказался шире, чем в ревью: проверка по всему набору нашла ещё два
файла со своей подпиской — smoke_cold_view_toggle и smoke_cold_view_vacuum.
Они не слепая зона, их страница приходит из launchColdView и уже
зарегистрирована, а свой счётчик они превращают в отдельное утверждение.
Поэтому инвариант сформулирован как «ни одна страница не создаётся мимо
гарда» и закреплён по всему набору, а не по двум названным файлам.
reportPageErrors() из #407 стал асинхронным: второй читатель счётчика обязан
ждать доставку так же, как finish(). Пять смоков получили await.
Фикстура smoke_danger_confirmation приведена к объявленному типу: без binding
и bindingMode _bindingHasHaPage падал на undefined.split(':') — два
исключения, которых гард не видел. Дефекта поведения нет, все 15 мест в src/,
создающих диалог, binding пишут; врала фикстура.
Два отступления от ТЗ, каждое по измеренной причине. Пробы лежат в
demo/guard/, а не demo/fixtures/: последний входит в корпус sourceFingerprint,
и каждый файл там объявил бы устаревшими бандл, скриншот-индекс и
golden-индекс — пробы же не касаются ни одного пикселя. Поведение
доказывается в job со браузером, а не в npm test: job «Фронтенд» браузеры не
ставит, и тест молча скипался бы — тот самый тихий успех, против которого вся
задача.
Issue: #404
User-Visible: no
hp-confirm sat at the end of a chain of early returns, so in onboarding
(«no spaces yet»), in the fixed-floor states and without a space it did
not exist at all: the trash button next to a saved plan was dead and the
promise hung forever, because the decision event had no source in the
DOM. An already open dialog vanished the moment the card slipped into
one of those branches, leaving the caller waiting for a resolution that
could never come. Before #32 a browser confirm() worked there.
render() is now a wrapper: it takes the body — the old chain, unchanged,
as _renderBody — and renders the confirmation beside it. That fixes the
class rather than the instance: a branch added later cannot lose the
dialog again. noChange and nothing are passed through untouched, since
neither may be wrapped in a template; in those states _confirmDanger
refuses the request outright instead of leaving it pending, which is the
honest answer while the card is not on screen and the user has pressed
nothing.
_tapConfirm and _vacCalConfirm deliberately stay where they are. They
share the same final branch, but they have no promise (a synchronous
exec, a dialog closed by hp-close), so the defect cannot occur there,
and their entry points require a drawn plan.
Proven by a separate smoke rather than an addition to
smoke_danger_confirmation: that file keeps deliberately incomplete
dialog fixtures open, and the extra re-renders this change needs make
them throw. The new smoke runs under touch emulation, because
TOUCH-SUPPORT § Safety floor forbids bypassing a destructive
confirmation and the broken branch pierced that floor on finger as
surely as on mouse. Reverting the wrapper reddens it.
User-Visible: yes
Issue: #402
CODE-REVIEW-400-r1 Medium: the registered mutant edited a comment, not
the order — it could not reproduce the regression AC1 exists to catch.
That is the same defect class this issue is fixing elsewhere, in my own
guard.
The order is now HANDLE_PAINT_ORDER, a named constant, because it IS the
hit priority rather than an accident of where the blocks sit in the
template. The mutant flips that constant, so it reproduces exactly the
behaviour the audit found.
Also: smoke_furniture picked the SE corner as handles[3], an index that
silently depended on the old paint order — CI shard 3 went red on four
checks. It now selects by role (corner handles, third of four), which is
what the test actually means.
User-Visible: no
Issue: #400
(1) Corner and edge handles carry the same hit radius (1.8 % of the
view), so on furniture narrower than 4·hr — a 40 cm cabinet — the two
circles overlap and whichever is painted last takes the tap. Edges were
painted last. Corners are now, because a side handle scales one axis
while a corner scales both, and the object is small exactly when
proportional resize matters most. The visible beads are unchanged.
The audit called this 'proportional resize becomes unavailable'; the
measurement says otherwise and the spec records the correction: the
corner centre lies outside the edge circle, so the corner was reachable
— its area was halved, not lost. A polish, not a bug, and worth fixing
because it is one line of ordering.
(2) Alignment guides in the devices mode excluded the dragged marker by
_drag, which has been null there since #74 moved device dragging into
_deviceDrag. So the marker being moved was among its own candidates.
Nothing looked wrong because a point always matches itself within
tolerance — the guide was drawn from the marker to itself, visually
identical to an honest one, and the smoke asserted only guides() >= 1.
The smoke now compares the candidate lists with and without the drag and
demands exactly one removed entry.
(3) The 38 settings-help strings stay in the initial chunk, and that is
now a recorded decision rather than an oversight: measured 2 654 B gzip,
0.9 % of the ceiling, against splitting a synchronous dictionary in two,
a second request on first hint, and a second source for the key type
derived from en.json (#391). docs/ARCHITECTURE.md says so, with the
number that would justify revisiting it.
Both mutants run by hand: reverting the paint order reddens the 40 cm
probe while the 160 cm one stays green; restoring _drag reddens the
guides smoke.
User-Visible: yes
Issue: #400
CODE-REVIEW-399-r1 High: the test named after AC5 called
installsPythonDeps on string literals and never executed the directory
walk it was supposed to protect. The reviewer showed what that costs:
restoring the old hardcoded pair of real names and dropping a third
workflow with unpinned installs into .github/workflows left all ten
checks green — the exact scenario AC5 describes went undetected.
The walk is now a function taking the directory, so the test can run it
for real: it builds a temporary directory with three files (a pinned
installer, a workflow that installs nothing, and a rogue one) and
asserts on what the scanner returns. Reverting the walk to a list of two
real names now reddens this test, verified by hand.
The mutant is sharpened accordingly: it substitutes the two-name list
instead of a one-name list. The old form failed on an unrelated
assertion about directory size, so it proved nothing about the scan
itself — while the two-name form is indistinguishable from correct code
on today's tree, which is what makes it the likely regression.
User-Visible: no
Issue: #399
Three claims a green backend used to make, each slightly wider than the
truth — and #392 happened in exactly that gap.
The frontend pin said 20260826.1 next to homeassistant==2026.8.3, whose
package_constraints.txt requires 20260729.7: a combination that exists
in no HA release. It was never derived from anything — someone once
picked it. It is now taken from the constraints, the source is named in
the file, and a test holds both numbers together so raising HA cannot
quietly desync them.
ruff's include declared three trees while CI linted one. Narrowed the
declaration rather than widening CI: the debt in scripts/ and
tests_backend/ (56 findings, mostly E402/I001, plus 7 B023 and 5 B017)
has its own cost and its own decisions, and belongs in its own task, not
in a visibility fix. test/lint-scope.test.mjs now compares the two, so
they can only move together.
The pin check skipped a workflow when it found neither the package name
nor the requirements path — and both vanish together the moment someone
returns to Defaulting to user installation because normal site-packages is not writeable, i.e. the gate switched itself off
under precisely the change it exists to catch. It now walks the whole
.github/workflows directory and decides per file by a positive sign: if
a file installs python packages, it must install them from the pins
file. Verified by dropping a rogue workflow into the directory — it
reddens without touching any list.
Three mutants registered and each run by hand.
User-Visible: no
Issue: #399
The guard introduced by #394 matched the literal
sys.modules['custom_components... and therefore never looked at
pure_imports.py, which writes through a variable — the third instance of
the #389 class walked straight past the check created for it.
The guard now inspects the write itself and decides by the key: a whole
literal or the literal head of an f-string is safe unless it starts with
custom_components (that is how tests register homeassistant.*, hp_pure.*
and houseplan.trails); anything else — a variable, a concatenation,
setdefault/update — counts as a violation whenever the file is able to
name the package at all, i.e. contains a custom_components. literal. A
file that never names the package cannot poison it through a variable,
so restoring a snapshot stays legal.
load_pure now removes what it registered. Removing its own name is not
enough: relative imports pull neighbours in, so junction_limits leaves
wall_segment_model and coordinate_canonicalization behind. It removes
the whole custom_components difference accumulated during exec_module,
in a finally, and a repeated call still works.
pure_imports.py is a named exemption of the static guard precisely
because that guard cannot see the cleanup — so the cleanup is proven by
an executable test instead, and the mutant pure-imports-stops-cleaning
reddens it. Both mutants were run by hand.
User-Visible: no
Issue: #398
B3: _persistDevicePlacement sent canonicalizePosition(...) to the server
and left the raw value in _layout, then recorded the fingerprint over
that raw snapshot. Canonicalization is not identity — it snaps to the
lattice — so 39 of 115 pixel-derived coordinates differ, and the next
_reloadLayoutOnly or _adoptStructuralResponses saw its own write as a
remote edit: history cleared, _layout replaced. The old _persistLayout
wrote the canonical value back; the per-device path introduced by #74
lost that line.
M1: the smoke that was supposed to prove AC10 assigned
serverLayout = structuredClone(c._layout) right before the reload —
erasing by hand the very divergence it existed to catch, so it could not
fail. The fake WS already stores what went over the wire; the
assignment is gone and the check now reddens on the unfixed code
(verified: three checks red without the fix, including this one).
Also proven, because the fix touches their neighbourhood: the echo of a
DELETE keeps the history (the branch removes a key rather than replacing
a value), and an in-flight write still wins the merge against a server
answer holding the old position.
One existing assertion was loosened deliberately: undo now restores a
position that may differ from the raw one by the lattice snap (<1e-9 of
the plan). That is the point of the fix — local and server agree — so the
equality is stated to that precision, with the snap size pinned
separately so a real drift would still fail.
User-Visible: yes
Issue: #397
Three findings of the v1.70.0-beta.1 audit, all on the transition path
added by #82, all of the same shape — the new path did not inherit a
property the old one had.
B1: persisting the zoom moved into _settleCameraTransition only, and a
cancellation never settles. Touching the plan mid-flight — the literal
scenario of the issue — froze the shown frame and threw it away; before
which kind it is: the user one (_stagePointerDown) persists the frame
that stays on screen, the eleven structural ones keep writing nothing.
The distinction is now also written down in spec #82 §13, which had one
line for both.
B2: the anchor was read from the presented (lagging) frame while the
zoom accumulated from the target, so a six-notch trackpad series walked
the point under the cursor 17 px away — against §10's own promise. Both
now come from the same state. Spec §10 said to use the presented frame
and to keep the anchor within 0.5 px; those two are incompatible, and
the paragraph is corrected rather than left as a trap.
M2: the feather freeze keyed on the two gesture flags, which an
animated transition does not set, so every tween frame rebuilt the blur
region. It keys on 'the camera is still' now.
Guards: unit tests pin the anchor at 1e-9 across 8/16/33 ms series and
prove zoom accumulation is untouched; the smoke checks the shown zoom is
the stored one, that a structural cancellation stores nothing, and that
the anchor holds; three mutants (cancel-loses-zoom, anchor-from-
presented, feather-thaws) were run by hand and each reddens.
User-Visible: yes
Issue: #396
r6 Medium: AC4 was measurable only on a developer's machine — no
workflow invoked mypy, so a typing regression in any of the six
allowlist modules reached dev unnoticed while the issue claimed
measurable backend quality. Coverage and lint had continuous gates;
typing had a text comparison of a committed list.
The backend job now runs mypy right after ruff, from the same pinned
dependency file (mypy==2.3.1 — an unpinned checker would redden on code
that never changed). The step derives its module list from the
pyproject.toml strict allowlist instead of duplicating it, because a
drifted duplicate is a green step checking the wrong modules, and it
refuses an empty list rather than passing silently.
Guarded twice: a contract test pins all three facts (pinned checker,
a step that really invokes it, list read from pyproject) and the new
typing-gate-stops-running mutant reddens when the invocation is
neutered.
User-Visible: no
Issue: #42
Tooling: requirements_test.txt becomes the single source of backend CI
dependencies; pyproject.toml configures ruff (E/F/B/I, E501 excluded by
decision) and mypy strict for a grow-only allowlist of six pure modules
(junction_limits annotated to pass). The 42 substantive ruff findings
are fixed — the B023 loop-variable closures bind their variables as
parameter defaults instead of hiding behind noqa, and every remaining
noqa carries a reason (guarded by a test).
Errors: const.ERROR_CODES / ERROR_CODE_FAMILIES formalise the stable
contract; the scanner test proves every emitted code across BOTH paths
(send_error literals; class attrs, literal and variable-passed
MarkerControlError codes, f-string families) is registered and has a
localized message — 22 missing backup.error.* keys added in all four
languages. invalid_passage_fields / invalid_partition_opening_jamb_margin
ship structured JSON details (legacy format read-compat for one beta),
and _errText renders code-first: unknown codes localize, raw English
messages go to the console.
CI: the backend job lints with ruff, refuses a silently skipped HA
harness (import + collect threshold), measures branch coverage over
pure+harness, fails below the committed baseline and uploads
coverage.xml. quality_scale: docs-troubleshooting/examples honestly
done, test-coverage/strict-typing carry staged progress.
User-Visible: yes
Issue: #42
Remove legacy any casts from device inbox, marker, and geometry preflight translation calls. Refresh the moved preflight mutation anchor.
Issue: #391
User-Visible: no
Add the agreed Party 1 help controls to general, space, marker and device-catalog settings in all four locales, including cold onboarding parity and regression coverage.
Issue: #86
User-Visible: yes
Light grouping and the excluded-integrations list move from hidden keys
to a Discovery-filters section on the catalog's Available tab: a toggle
(unset = on, the legacy behaviour), searchable integration chips with a
Restore-recommended reset (defaults stored as key absence), and
appear/disappear counters computed by diffing the REAL
seedHiddenBindings/buildDevices outputs — no second copy of the filter.
Saving writes settings once over the ordinary expected_rev path. Every
excluded candidate now names its integration in the catalog. Room
climate follows the same user exclusions through the single
effectiveExcludedIntegrations resolver (spec H2); explicit climate
opt-in stays stronger. The field registry passports both keys as
current supported settings.
User-Visible: yes
Issue: #44
repo-hygiene caught it: HACS globs *manifest.json over the whole clone
and rejects a repository with two. The dump is scripts/config-schema.json.
User-Visible: no
Issue: #33
The Voluptuous schema is now dumped into a deterministic committed
manifest (265 leaf paths); a pytest fails on drift. A parity test
compares manifest enums with the exported frontend const lists through
a machine-readable allow-list that also refuses to rot. The field
registry gains passports (allow-extra / lovelace-card), enforcedBy
citations for mechanisms that already shipped, and passports for the
v1.68-v1.69 fields; a completeness test bans dead decisions. Lifecycle
fixtures (oldest / current / future) prove lossless loading, and the
config auditor gains the 0/3/2 exit-code contract.
User-Visible: yes
Issue: #33
(a) a same-binding click in the marker dialog is a no-op: the value
source and badge reset only on an actual change of binding (#378 §1.6) —
both the candidate list and the virtual radio.
(b) rewriteMarkerControlReferences no longer plants value_badge /
value_source keys as undefined on markers that never had them.
(v) the expensive release diff proof (2 git-show per src file) runs only
for commits the SAME shared predicate classifies as release — both
disjuncts, including the Release: trailer.
(g) the paired neutralisation formats in space export are documented in
place and pinned by a combined badge+value_source pytest.
User-Visible: yes
Issue: #385
Wall bodies, extras and zero walls vote in the tight frame only while
show_borders renders them — mirroring the hideOpenings guard for opening
symbols. needsCanonicalWallGeometry returns to its pre-#373 form: the
union is no longer forced for extras-only plans just for the frame.
User-Visible: yes
Issue: #384
(а) title: null gets the same compact frame as title: '' — YAML 'title:'
with no value parses as null, the owner's decision makes them synonyms.
(б) the guides state that room labels are inert in the Background editor.
(г) furniture strokes skip the flat-camera compensation in the labs iso
projection, tracking ordinary decor there.
(д) TESTING.md notes the light_pools opt-in for static room cards.
(е) the space-card dispose gate mirrors the strict === true render gate.
User-Visible: yes
Issue: #376
settings.decor_default_style (all fields optional, validated) seeds
_decorStyle once from the first config that arrives; every UI change of
the session default flows through one runtime method with a 1s debounce
and the ordinary serialized expected_rev write path. The built-in default
is stored as the absence of the key; a partial or garbage key falls back
per-field. decorStyleFromSettings/decorStyleToSettings are the single
snake_case<->camelCase conversion point.
User-Visible: yes
Issue: #377
V6a: pass the stable devices array to resolvedLightSources — the WeakMap
cache is keyed by array identity, a spread guaranteed a miss on every
render in BOTH cards (full-card regression since beta.4).
V6b: the static wall geometry now carries the same non-enumerable
sourceFingerprint tag the full card attaches, so buildLightBarrierScene
takes the fast recutWallBodiesGeometry path on door state changes.
V6c: the static barrier-scene cache is an LRU of 8 per space (parity
with _lightBarrierPool) — a flipping door reuses both of its scenes.
V6d: enabledClip is cached by geometry fingerprint + disabled-room set,
with the full card's bbox prefilter for decor bodies.
User-Visible: yes
Issue: #375
The loadGerman-swap mutant tree-shook src/i18n/fr.ts, so the manifest
emit guard failed the mutant BUILD instead of the guard smoke. The
mutant now keeps import('./fr') alive and returns the English
dictionary, which only demo/smoke_french_locale.mjs can catch.
User-Visible: no
Issue: #371
The entry-removed variant died at build time (tree-shaking drops the fr
chunk and the manifest emit guard refuses the bundle) — an infrastructure
failure, not a red guard. The mutant now swaps the fr entry to the German
loader instead: everything builds, parity units stay green, and only the
French smoke catches the wrong dictionary.
Issue: #371
User-Visible: no
The complete French dictionary contributed in #371 (1026 keys, zero empty
values, zero placeholder mismatches) lands as the second lazy locale on the
fr.ts + one static entry. The contributor's snapshot predated this week's
keys, so the 121 additions (device inbox #29, backdrop guard #39, junction
limits #331, lazy-editor toasts #353/#354, furniture #159) are translated
in this commit and flagged in the issue for the author's review; 21 stale
pre-#62 keys are dropped; key order follows en.json. The HA integration
translations file ships as contributed (full parity).
Wiring: loadFrench + __HOUSEPLAN_FR_RETRY_ASSET__ with the same 1/1
replacement guarantee as German; locale roles and localeRoots generalise to
(de|fr); the stale-entry fallback panel (#353) gains its French branch —
the r1 reviewer caught that this second hardcoded language list would have
silently degraded French to English on a cached entry. French profiles
(fr, fr-FR, fr-CA, fr-BE, fr-CH) select automatically.
Proofs: the registry-driven parity suite covers fr by construction
(1128/1128 keys); French analogues of both German-personal tests (product
glossary + non-translation scan with a reviewed equal-to-English
allow-list of 22 legitimate homographs); smoke_french_locale — fr-CA
profile commits French from the real bundle, one lazy chunk per page,
initial graph free of fr; smoke_entry_stale gains the French run; a
registry mutant drops the fr entry and is killed by the smoke. Initial
view: +0.5 KB (registry entry + fallback branch); the 23 KB dictionary is
lazy.
Issue: #371
User-Visible: yes
Запас ушёл с 26 КБ до 8.3 КБ за сутки. По документам код-ревью видно, что это не
диффузное расползание, а один шаг плюс обычная работа:
#317 256127 · #318 256091 · #341 256046 · #354 257212 · #159 256828
#357 271143 <- +14 КБ за один заход
#20 271455 · #361 272848 · #359 272469 · #360 273697 <- текущий факт
Шаг на #357 — plan-art мебели: 44 top-view символа в eager-графе, которые платит
каждый план, включая планы без единого предмета мебели.
Потолок 282000 -> 300000. Правило #352 сохранено: 273697 x 1.10 = 301067, то
есть 300000 остаётся внутри надбавки ~10% над измеренным фактом. Запас
возвращается к 26.3 КБ — примерно к тому, что было до #357.
Запись честная и в комментарии сказана прямо: рекалибровка ничего не ускоряет и
ничего не чинит. Она фиксирует новую норму и возвращает гейту способность красить
того, кто вырастил бандл, а не того, кто пушнул последним. Настоящий рычаг —
ленивый граф, варианты 1 и 2 из #367.
Добавлено предупреждение: пока запас меньше 15000 Б, гейт печатает ::warning:: и
строку в summary. Прежняя редакция полагалась на то, что человек заметит тренд в
выводе; за сутки его не заметил никто, потому что каждая отдельная строка
выглядела нормально. Текст предупреждения называет лечение — иначе следующий
читатель поднимет потолок ещё раз и назовёт это решением.
Тест закрепляет обе стороны: надбавка не больше 10% и не меньше 5% (меньше —
возврат лотереи «красит последний коммит»), тревога срабатывает строго ниже
порога, превышение описывается как превышение. Три мутанта проверены руками,
один добавлен в реестр.
Issue: #367
User-Visible: no
(a) documented: deleting a vacuum marker erases its server trail at once
and a re-added marker starts from scratch (VACUUM.md + both USER-GUIDEs).
(b) smoothVacPath reports dropped non-finite segments — one console warn
per call with the count — instead of hiding the whole trail silently on a
broken calibration matrix. (c) room climate (#317) now reaches legacy
markers whose exported config carries an ABSENT area key rather than an
explicit null: `== null` where the placement is decided. (d) the armed
furniture preview follows Shift without mouse movement — window
keydown/keyup listeners live exactly as long as the palette is armed,
detached at every palette teardown. (e) only the primary mouse button
places decor/furniture: a right or middle click with an armed tool is a
no-op, touch/pen untouched. (f) a device whose registry entities were ALL
deliberately disabled by the user no longer glows as an alive controller —
the #318 entityless-active rule now requires a genuinely empty roster.
(g) furniture-pack author corrected to Sergey Matyunin (Сергей Матюнин)
per the owner's decision — LICENSE.md, README.md, pack.json,
docs/FURNITURE.md, the provenance check in generate-furniture-assets and
its unit; the source archive bytes are unchanged and the README notes the
romanisation fix.
Proofs: units for (b)/(c)/(f) including the #318 regression pair; new
smoke_furniture_polish for (d)/(e) with listener add/remove counters and
both mouse buttons; five registry mutants, one per code change.
Issue: #369
User-Visible: yes
A gate or door bound to a position-reporting cover fed current_position
into the light-barrier signature at toFixed(3) precision: every percent of
movement produced a new fingerprint, a full physicalBodyParts recompute
and a recut — up to ~100 heavy passes per gate cycle, plus LRU churn.
The light pipeline now consumes one quantised amount
(OPENING_LIGHT_AMOUNT_QUANTUM = 0.05, exact 0 and 1 nodes) at the single
point that feeds BOTH the signature and the cut geometry, so the cache key
and the drawn aperture agree by construction and a full sweep costs at
most 21 recomputes. The door LEAF animation stays smooth — _openingAmt is
quantised only for the light pipeline, nowhere else. Binary contact doors
are byte-identical to the previous behaviour (pinned by unit).
Assumption recorded in the spec: the 5% visual step of the light cut is
indistinguishable on real plans; if field impressions disagree, the
quantum is a one-constant change (0.02 => <=51 recomputes) or the decision
falls back to a debounce. LIGHT.md §Caching documents the grid.
Issue: #366
User-Visible: yes
28.08 коммит bb2919f уехал в dev с тридцатью файлами вместо одного markdown:
откатил отревьюженную реализацию #359, вернул старые чанки, оставил в dist/
двойной набор. dev держал откаченное дерево три часа. Сообщение коммита было
невинным, и от рутины инцидент отличался только диффом.
Механизм воспроизведён локально, а не предположен. `git checkout -- .`
восстанавливает рабочее дерево ИЗ ИНДЕКСА, `git clean -fd` убирает
неотслеживаемое — ни то, ни другое индекс не трогает. Ревьюер работает с Bash и,
проверяя «умеет ли тест падать», вполне может сделать git add; всё оставшееся у
него в индексе прежняя уборка сохраняла, и следующий git commit забирал это
вместе с документом.
Отсюда три рубежа, каждый закрывает свой отрезок пути.
База: reset --hard на свежий origin/$target снимает и индекс, и дерево разом.
Терять нечего — документ приезжает из RUNNER_TEMP, а не из рабочей копии.
Индексируется ровно один путь, а не каталог.
Индекс: перед коммитом дифф проверяется allowlist'ом docs/reviews/.
Диапазон: перед КАЖДЫМ push проверяется origin/$target...HEAD — то есть то, что
пуш добавит в ветку. Проверок две, потому что push делается из двух мест, и
второй путь срабатывает ровно тогда, когда dev ушёл вперёд — в тех самых
условиях, при которых случился bb2919f.
Пустой дифф — тоже отказ: публиковать нечего означает, что документа нет, а
прежняя редакция шага выходила тут с нулём и оставляла вердикт без артефакта
(#171). Сравнение по префиксу каталога, а не подстрокой: docs/reviews-old и
docs/reviewsx разрешёнными не считаются. Форс-пуш отсутствует и закреплён тестом.
Четыре мутанта проверены руками, два добавлены в реестр. Пятый — «убрать одну из
двух проверок диапазона» — сначала выжил: тест требовал наличия, а не количества.
Тест усилен до подсчёта, мутант убит.
Issue: #365
User-Visible: no
r1-M1: dismissal (Escape/scrim/Cancel) while the reduce or keep-original
flow is executing no longer races the decision — hp-close is ignored while
busy, and every flow re-checks it still owns the guard before applying, so
a force-cleared dialog can never silently install its stale result. The
smoke now drives both: hp-close during a hanging decode leaves the busy
dialog up, and a force-cleared guard ends with clean staging, no toast, no
planFile. A new registry mutant removes the busy gate and is killed.
r1-M2: the hard-dialog text takes its limit from the imported
HARD_DIMENSION instead of a literal — recalibration stays a one-file
change, as the spec promises.
r1-M3: AC8 is now proven end to end, not plausible: the smoke splices a
real EXIF APP1 (orientation 6) into a canvas-encoded 8200×4100 JPEG,
asserts the header probe reads the unrotated SOF, that the decode call
carries imageOrientation:'from-image' (captured on the hook), and that the
reduced copy comes out portrait 2048×4096. TESTING.md names the scenario.
Issue: #39
User-Visible: no
A picked raster is now classified from its HEADER BYTES ONLY before anything
heavy happens: src/backdrop-probe.ts parses PNG IHDR (+colour type/tRNS for
alpha), JPEG SOF and WebP VP8/VP8L/VP8X at fixed offsets, never using a file
field as an allocation size; hostile or truncated headers collapse to
'unknown', which warns without numbers instead of passing silently. The
thresholds live in that module as the single calibration point
(WARN_DECODED_BYTES 128 MiB ≈ 32 MP, HARD_DIMENSION 16384 — the browser
canvas cap, DOWNSCALE_TARGET_PX 4096), derived from the desktop-Chromium
matrix now committed as demo/benchmark_backdrop_decode.mjs with a
conservative tablet margin documented in the spec.
The shared pick flow (src/backdrop-pick.ts) feeds BOTH lazy runtimes — the
editor space dialog and the onboarding first-space dialog — so the guard
cannot drift between them, and nothing of it enters the eager View graph.
Warn shows the real numbers and three actions; the reduced copy decodes
EXIF-aware, keeps aspect and alpha (PNG stays PNG, opaque becomes JPEG
q0.9) and flows through the ordinary planFile → upload path. Hard has two
phases with one outcome: beyond 16384 px only Cancel; a failed or timed-out
(10 s) reduce closes with a toast, clean staging and NO silent fallback to
the original the user just declined. SVG never reaches the probe. The safe
path swaps the manual byte-loop base64 for FileReader — half the JS-heap
peak on every upload, byte-identical output (parity asserted in the smoke).
Proofs: header-table units incl. a fuzz set of hostile headers and ±1
threshold bounds; smoke_backdrop_guard on the real bundle — zero decode
calls before the choice, byte parity of keep-original, a real 6200 px
reduce to 4096 for both alpha and opaque branches, cancel-only hard
dialog, both phase-2 failures (reject and hang under the test-only timeout
override), re-pick after refusal, SVG bypass; four registry mutants
(probe-always-safe, alpha-dropped, hard-demoted, phase-2 silent fallback).
Spec anchor corrected alongside: the server plan limit is 8 MB
(MAX_PLAN_BYTES), attachments are the 50 MB path — an 8 MB JPEG is easily
80-160 MP decoded, so the client-side guard stays the primary defence.
Issue: #39
User-Visible: yes
Systematic audit after #357 ("can there be more bugs with this root
cause?"): _vacMapId was the one remaining hard stub reachable from the
eager View path. It runs inside willUpdate for every vacuum whose
integration reports live telemetry (Tasshack, XCME, Valetudo), so on a
cold tab the #337 stub threw there and the exception took the whole Lit
update cycle with it — the card froze on its very first frame. The demo
mower has no position attributes, telemetry resolved to null, and every
existing smoke (warm and cold) sailed past the branch.
The card now owns the implementation (both dependencies — _vacEntity and
vacMapIdWithFallback — were already eager); the editor runtime delegates
back to the host. The HP-1541-01 invariant (selected_map: 0 is a real map
id, nullish not truthy) moves verbatim and is pinned by the new smoke.
Hardened alongside (audit Lows): _decorShapeDown gets the same
cold-tab guard its twin _decorShapeDbl received in #337 — decor shapes
render in View and CSS pointer-events alone must not be what prevents a
throw; the _vacCalConfirm dialog renders behind the same _editorRuntime
gate as every other editor dialog instead of relying on the implicit
"only the runtime ever sets it".
smoke_cold_view_vacuum: cold tab, vacuum with vacuum_position and
selected_map: 0 — the card commits three successive telemetry frames
(willUpdate alive, not merely the first paint), map id resolves to '0',
no editor chunk requested, a decor pointerdown is a quiet no-op. A
registry mutant restores the delegation and is killed by that smoke.
Issue: #358
User-Visible: yes
Field report from the dacha: the wall switch "Гостиная основной свет",
whose controls name three virtual light sources, periodically ignored taps
— no toggle, no glow — until its settings dialog was opened once with no
changes. "Periodically" was every fresh tab: the #337 lazy split left
_toggleIntent (and the confirm-line helpers) on the card as stubs
delegating into the editor runtime, so a plain View tap on a cold tab
threw `Houseplan editor runtime is not loaded` synchronously inside the
click handler. Opening any editor surface loaded the runtime and "healed"
the tab for its lifetime.
The View card now owns toggle resolution: _toggleIntent calls
resolveToggleIntent directly (device-toggle.ts was already in the initial
graph; the card owns _planHass/_fullRegistryHass/_virtualLights), and
_toggleStateText/_toggleConfirmationStateText/_toggleConfirmationLines
moved with it. The editor runtime delegates back to the host — one source
of truth, editor consumers (dialog preview, hint lines) unchanged.
Every product smoke preloads the runtime, so none of them could see this
class of regression. The new smoke_cold_view_toggle mirrors the field
config on a genuinely cold tab: a real switch drives three passive
virtual lamps with one tap, a controlled lamp drives its switch back,
tap_confirm renders its state lines and confirms, and the editor chunk is
never requested. A registry mutant restores the old delegation and is
killed by that smoke.
Issue: #357
User-Visible: yes
The r1 reviewer cut the listener loop in the production registry and all
three #354 units stayed green — the subscription unit was the same class of
decoy the issue itself fights. The fan-out now lives in an exported
notifyLanguageLoadFailures(code); the unit drives it directly and asserts
real delivery, partial unsubscription and silence after the last listener
leaves; the contract unit additionally pins the runtime wiring
(`loadFailed` → notifyLanguageLoadFailures) in source. A new registry
mutant `locale-failure-delivery-cut` replays the reviewer's exact cut and
is killed by the unit. The r1 Low is taken too: both USER-GUIDEs now
mention the toast in the German-failure paragraph.
Issue: #354
User-Visible: no
The production LANGUAGE_RUNTIME was a handwritten twin of the tested
LanguageRuntime class (germanDictionary/Pending/Failed): equivalent on the
day it was written, invisible to every i18n-runtime test afterwards. The
registry now exports one page-scoped `new LanguageRuntime(LANGUAGE_REGISTRY,
…)` instance — the whole existing suite starts proving the object production
actually runs, and a contract unit (instanceof + source free of the old
field names) keeps the duplicate from returning.
The class gains an optional `loadFailed(code)` hook — fired once when a
dictionary load settles into English fallback — and the registry fans it out
through `subscribeLanguageLoadFailures`. Only the View card subscribes (it
alone owns toast infrastructure): a failed language pack now shows the new
`toast.locale_load_failed` message (en/ru/de) instead of a console-only
warning; space card and both GUI editors keep the console warning as before.
Proofs: contract unit, hook unit, subscription unit; smoke_german_locale
extended — the both-attempts-failed scenario now asserts the visible toast;
two new registry mutants (handwritten-twin returns, toast dropped).
Issue: #354
User-Visible: yes
Network failure of the editor runtime is no longer terminal: the loader
re-arms to idle and the next explicit press starts a fresh cycle, while a
fingerprint mismatch on either attempt stays terminal. The toast now says
what actually helps — retry advice for the network, refresh advice for a
foreign build — via one shared lazyLoadFailureMessage helper (new i18n key
editor.retry_advice in en/ru/de).
The field smoke caught a second, deeper bug on the way: Chromium records a
FAILED module in the page module map permanently, so retrying the same URL
(even the cache-busted one) never touched the network again. Every retry
now carries a per-cycle nonce and becomes a genuinely new module request.
A proxy-cached stale entry no longer kills the card silently: the entry
facade is rewritten at build time from a static re-export into a top-level
`try{await import(...)}catch{...}` — importers keep the happy-path
guarantee (await import(entry) still resolves only after
customElements.define), and the catch defines a fallback element with a
localized "reload the page" panel. Content-hashed chunks are served with
`public, max-age=31536000, immutable`, and verifyBundleTree now fails on
orphan chunks that the manifest does not name.
Proofs: loader units for re-arm/terminality/toast wording + an AST check
that both loaders forward the terminality flag; smoke_entry_stale (en/ru)
against a tree without the main chunk; smoke_lazy_editor_chunk extended —
second press after network failure now really opens the editor; pytest for
the immutable header; orphan-tree unit; five new registry mutants.
TESTING.md budget line updated to the #352 ceiling alongside.
Issue: #353
User-Visible: yes
В src/** сейчас 1034 вхождения явного any в 49 файлах — больше, чем называл
аудит (330), потому что монолит с тех пор разделился и его обвязка уехала в
houseplan-editor-runtime.ts. Разовая замена такого объёма — месяц риска ради
нуля пользовательской ценности, поэтому долг снимается при плановом извлечении
подсистем (#34). Задача гейта одна: не давать долгу расти.
Судятся только добавленные строки диапазона. Изменённая строка со старым any
выглядит в диффе добавленной, и это намеренно: тронул — либо типизируй, либо
обоснуй на той же строке `// any-ok: <причина>`. Голый маркер, пустая причина и
шаблоны вроде todo, hack, потом не проходят.
Ложных срабатываний нет по построению, а не по старанию: текст разбирается
парсером TypeScript, и нарушением считается узел AnyKeyword. Регулярка по строке
ловила бы слово any в прозе внутри шаблона html и в комментариях; здесь
комментарии, строковые литералы, многострочные шаблоны и идентификаторы
company, anyOf, manyRooms узлами такого вида не являются вовсе.
Проверено исполнением на настоящем дереве, а не только юнитами: пробные коммиты
в src/wall-thickness.ts показали, что добавленный any падает с файлом и строкой,
типизированная строка в файле с 122 старыми any проходит, any-ok с конкретной
причиной проходит, а голый и «todo» — нет, и что any в прозе, строке и
идентификаторах не даёт ни одного срабатывания.
В job frontend checkout получил полную историю без блобов: diff-aware проверке
нужен диапазон, а содержимое старых ревизий — нет.
Заодно закрыта ловушка в test/validate-workflow.test.mjs: имя job искалось через
indexOf(' frontend:'), а эта строка встречается внутри ` frontend: ${{ ...
}}` в outputs job changes, поэтому срез уходил не туда. Теперь имя ищется с
начала строки.
Четыре мутанта проверены руками, два добавлены в реестр: гейт, судящий все
строки, и гейт, принимающий голый маркер.
Issue: #342
User-Visible: no
The owner's decision (2026-08-28): optimize is one of the two commands a
client can use to write arbitrary geometry, so it validates its candidate
against the stored document exactly as config/set does — inheritance counted
per rule (repairing a legacy plan with violations still passes; #329 AC10
already proves an honest optimization adds none, so the gate is a no-op for
legitimate flows), while a crafted payload is refused with the stable
junction_limit_<rule> code the except list has been ready for since #329.
The call lives inside the existing executor function, and a successful
optimize refreshes rt.junction_baseline with the candidate's counts so the
next config/set inherits from the cache (#330 §4.2 symmetry).
Import and backup restore stay OUTSIDE the gate on purpose — #329 §3
promises a restore is never blocked. The module docstring stops promising
more than the code does, and spec #329 §5 records the perimeter and the
trade-off explicitly: a crafted import can persist violations, but they are
inherited, never legalised as new ones.
HA tests pin AC1 (crafted spike refused, stored config and rev
byte-unchanged), AC2 (echo-optimize of a stored plan that already carries a
violation passes) and AC3 (the follow-up config/set takes its baseline from
the cache — observed through a recording wrapper). The
junction-limit-optimize-unguarded mutant turns AC1 red through the
backend-test-guard convention.
Issue: #333
User-Visible: no
Red dev caught it ninety minutes after the merge: smoke_plan_drawing_repairs
and smoke_resize_pointer_real_plan went red because the new "a 0° wedge is
always a duplicate" rule refused two ordinary edits — creating a room over
an existing partition ring (#308's legal overlay) and resizing a wall until
it lands on a neighbour's. The premise was wrong at the model level: a
shared wall of two adjacent rooms IS two co-located owner atoms on one line,
so every shared-wall node carries a legitimate 0° pair by construction.
Bisection pinned the exact cut: with only the 0° rule reverted, both smokes
are green again; keys, incidence, the iterative walk and fail-closed stay.
Spec revision 4 records the revert and returns "an exact duplicate wall is
invisible to П1" to the status of a KNOWN LIMITATION — an honest detector
needs owner identity, which is a separate decision for the owner to make.
The zero-wedge mutant is removed with its rule; the .5-tick parity unit now
observes quantisation through valence instead of the retired duplicate
visibility; changelogs drop the over-promise.
Issue: #331
User-Visible: yes
Running the mutants exposed two toothless guards before review did:
- reverting the keys to toFixed(6) no longer produced false П4 refusals
because the new 2e-7 incidence absorbed the debris pair — the REAL harm of
coarse keys is the opposite direction: nodes 4e-7 apart merged into one
key and П4 went blind to a genuine near-miss. AC1 now pins that case.
- the `break` patch failed to reproduce the old first-branch-only loss (the
frontier re-visits the node through the pushed endpoints); the patch now
truncates the node's candidate list to one entry, which loses forks the
way `.find` did — both the fork unit and the 10 000-atom run turn red.
Issue: #331
User-Visible: no
Six normative cuts, both mirrors symmetric (spec revision 3):
- §2.1 node keys quantise to 1e-7 with the repository's canonicalisation
formula (sign·floor(|v|·1e7+0.5)/1e7, -0 normalised) — toFixed(6) keys
split one node into two on floating debris and produced two false П4
refusals on a legitimate resize (reproduced: -1e-8 vs 0). Node pairs
within 2e-7 of each other (raw coordinates) are ONE node, and the
node-to-wall incidence uses the same quantum.
- §2.2 a ~0° wedge IS a violation: two rays leaving a node the same way are
a duplicated or overlaid wall (a butt joint yields 180°, never 0°) — the
worst degenerate case was invisible while 0.5° was refused.
- §2.3/§2.4 the wall run is an iterative edge walk over the collinear
component: no recursion (10 000 atoms answered, not RangeError), no
silently dropped fork (the old .find lost every branch but the first),
O(E) by construction, and collinearity is measured against the BASE
segment's axis so an arc of 0.9°-per-atom pieces cannot pose as one wall.
- §2.5 an exception while judging the CANDIDATE refuses the write with the
junction.limit_check_failed toast (fail-closed, as the #278 guard); the
baseline branch stays fail-open by design and the smoke proves the
asymmetry by breaking only the second call of the deterministic pair.
- §2.6 the python mirror narrows its except on the candidate side only:
a genuine migration bug (TypeError) surfaces as an honest WS error, while
a previous-side bug keeps the wide "no baseline" fallback — the two AC6
cases pin the asymmetry so swapped sides turn a unit red.
Parity fixtures gain the new boundary classes (debris node, duplicate wall,
collinear fork); four new mutants pin the filter, the key precision, the
dropped branch and the fail-open hole.
Issue: #331
User-Visible: yes
The reviewer proved my behavioural claim false by running the stale-cache
mutant against the smoke: 11 vs 12 total calls — indistinguishable. Two real
defects hid behind that finding:
1. The cache keyed on _cfgEpoch, which ticks on every ACCEPTED PREVIEW —
the cache missed on every pointermove and the baseline was recomputed
~4 times per gesture (measured). The key is now the document identity
plus spacePhysicalGeometryFingerprint of its space: content, not a
counter. A preview overlay leaves the fingerprint alone; an in-place
structural commit changes it and honestly invalidates.
2. The smoke now counts BASELINE computations only (calls whose document is
_serverCfg) across two gestures with a commit in between, expecting
exactly 1 then exactly 2. A disabled cache lands near 20, an eternal
cache stays at 1 — every mutant class turns the smoke red, and the smoke
is now the mutant's guard alongside the source-contract unit.
Issue: #330
User-Visible: no