// Mutation declarations only. Runtime, selection and evidence code lives in // separate modules so adding a witness cannot conflict with runner changes. import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; const repoRoot = fileURLToPath(new URL('..', import.meta.url)); const occurrenceCount = (source, needle) => source.split(needle).length - 1; /** * Map a logical pre-split card anchor onto the lazy editor source. * * Keeping the mutation definitions in their original logical form makes the * registry readable and preserves the history of each regression. At runtime * we relocate only anchors that actually moved into the typed editor host. * The mapping is deliberately strict: an ambiguous or missing anchor still * fails the cheap mutation applicability gate. */ function relocateEditorPatch(patch, cardSource, editorSource) { if (patch.file !== 'src/houseplan-card.ts' || occurrenceCount(cardSource, patch.find) === 1) return patch; if (occurrenceCount(editorSource, patch.find) === 1) { return { ...patch, file: 'src/houseplan-editor-runtime.ts' }; } const normalized = editorSource.replaceAll('this.host.', 'this.'); if (occurrenceCount(normalized, patch.find) !== 1) return patch; const normalizedStart = normalized.indexOf(patch.find); let rawIndex = 0; let normalizedIndex = 0; while (normalizedIndex < normalizedStart) { if (editorSource.startsWith('this.host.', rawIndex)) { rawIndex += 'this.host.'.length; normalizedIndex += 'this.'.length; } else { rawIndex += 1; normalizedIndex += 1; } } const rawStart = rawIndex; const hostFlags = []; let findIndex = 0; while (findIndex < patch.find.length) { if (patch.find.startsWith('this.', findIndex)) { hostFlags.push(editorSource.startsWith('this.host.', rawIndex)); } if (editorSource.startsWith('this.host.', rawIndex)) { rawIndex += 'this.host.'.length; findIndex += 'this.'.length; } else { rawIndex += 1; findIndex += 1; } } const rawFind = editorSource.slice(rawStart, rawIndex); let replacementThis = 0; const rawReplace = patch.replace.replaceAll('this.', () => { const host = hostFlags[replacementThis++] === true; return host ? 'this.host.' : 'this.'; }); return { ...patch, file: 'src/houseplan-editor-runtime.ts', find: rawFind, replace: rawReplace, }; } // --- реестр --------------------------------------------------------------- // `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда // попало», проверяет не то, что объявлен проверять. Это контролирует --check. const MUTANT_DEFINITIONS = [ { id: 'stairs-continuous-transform-snaps-to-lattice', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/coordinate-canonicalization.test.mjs', because: '#663 AC2/AC9/AC11: a wall-face magnet is continuous and save/load or Optimize ' + 'must not pull the stair back to a grid node.', patches: [{ file: 'src/coordinate-canonicalization.ts', find: " scalarFields(stair, ['x', 'y', 'length', 'width', 'radius', 'angle']);\n", replace: " latticeFields(stair, ['x', 'y', 'length', 'width', 'radius', 'angle']); // mutant\n", }], }, { id: 'stairs-area-does-not-subtract-footprints', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/stairs.test.mjs', because: '#663 AC4: stair footprints reduce the clean room area once while leaving the ' + 'visible floor polygon intact.', patches: [{ file: 'src/stairs.ts', find: ' return difference(source, ...footprints);\n', replace: ' return source; // mutant: footprints no longer affect clean area\n', }], }, { id: 'stairs-summary-area-clips-all-footprints-in-one-task', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/stairs.test.mjs', because: '#663 AC13: the maximum 250 stair footprints must be clipped in bounded slices; ' + 'one polyclip sweep blocks the summary panel main thread on slower clients.', patches: [{ file: 'src/stairs.ts', find: ' batchSize = 24,\n', replace: ' batchSize = MAX_STAIRS_PER_SPACE, // mutant: one long main-thread clip\n', }], }, { id: 'stairs-view-pan-opens-target-floor', guard: 'node demo/smoke_stairs.mjs', because: '#663 AC6: a pan or pinch beginning on a stair must not be promoted to the ' + 'compatibility click that navigates to another floor.', patches: [{ file: 'src/stairs-view.ts', find: ' if (!active || this.owner._suppressClick || this.suppressClick\n', replace: ' if (!active\n', }], }, { id: 'stairs-fixed-floor-still-navigates', guard: 'node demo/smoke_stairs.mjs', because: '#663 AC8: fixed-floor cards show stairs but never promise or perform navigation.', patches: [{ file: 'src/stairs-editor-model.ts', find: " if (fixedFloor) return 'fixed';\n", replace: ' if (false && fixedFloor) return \'fixed\'; // mutant: fixed-floor guard removed\n', }], }, { id: 'stairs-link-auto-creates-target-object', guard: 'node demo/smoke_stairs.mjs', because: '#663 AC5: assigning a target is a one-way link and must not invent a mirrored ' + 'stair on the destination floor.', patches: [{ file: 'src/stairs-editor.ts', find: ' this.replace(next);\n' + ' this.owner._recordGeometry(this.owner._t(\'history.stair_edit\'), before);\n', replace: ' this.replace(next);\n' + ' const mirror = (this.owner as unknown as { _serverCfg: { spaces: Array<{ id: string; stairs?: Stair[] }> } })._serverCfg.spaces.find((item) => item.id === dialog.targetSpaceId);\n' + ' if (mirror) mirror.stairs = [...(mirror.stairs || []), { ...next, id: `${next.id}-mirror` }];\n' + ' this.owner._recordGeometry(this.owner._t(\'history.stair_edit\'), before);\n', }], }, { id: 'stairs-handle-cursor-ignores-bearing', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/stairs-box.test.mjs', because: '#676 AC4: the cursor over a handle must say which way it moves in world terms, ' + 'so a rotated stair cannot show one diagonal arrow on every node.', patches: [{ file: 'src/stairs-box.ts', find: " if (sector === 0 || sector === 4) return 'ew';\n", replace: " if (sector >= 0) return 'nwse'; // mutant: one cursor for every handle\n", }], }, { id: 'stairs-dialog-clamps-to-wall-thickness', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/stairs-box.test.mjs', because: '#676 AC5: the properties dialog converts stair sizes, not wall thickness; a ' + '100 cm ceiling silently shrinks every real flight on save.', patches: [{ file: 'src/stairs-box.ts', find: ' return cm >= STAIR_MIN_CM && cm <= STAIR_MAX_CM ? cm : null;\n', replace: ' return Math.max(1, Math.min(100, cm)); // mutant: the wall-thickness clamp is back\n', }], }, { id: 'stairs-resize-mirrors-past-anchor', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/stairs-box.test.mjs', because: '#676 AC2: a pointer dragged past the anchor side clamps at the minimum size; ' + 'mirroring would silently move the ascent to the other side of the anchor.', patches: [{ file: 'src/stairs-box.ts', find: ' let w = handle.sx ? Math.max(minimum, handle.sx * lx + box.w / 2) : box.w;\n', replace: ' let w = handle.sx ? Math.max(minimum, Math.abs(handle.sx * lx + box.w / 2)) : box.w; // mutant: mirrors\n', }], }, { id: 'stairs-move-magnet-turns-any-angle', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/stairs-box.test.mjs', because: '#676 AC3: the move magnet may align a nearly parallel side by the tolerance only; ' + 'turning a stair standing at 30° to the wall changes its orientation behind the user.', patches: [{ file: 'src/stairs-box.ts', find: ' const snap = snapEdgeToFaces(edge, surfaces, reach, angleTolDeg);\n' + ' if (snap && (!best || Math.abs(snap.offset) < Math.abs(best.snap.offset))) best = { edge, snap };\n', replace: ' const snap = snapEdgeToFaces(edge, surfaces, reach, 89); // mutant: any angle snaps\n' + ' if (snap && (!best || Math.abs(snap.offset) < Math.abs(best.snap.offset))) best = { edge, snap };\n', }], }, { id: 'stairs-gesture-click-reaches-plan-tool', guard: 'node demo/smoke_stairs.mjs', because: '#676 AC6: the click the browser synthesizes after a handle drag must not reach ' + 'the plan tool: under Stairs it placed a copy, under Select it dropped the selection.', patches: [{ file: 'src/stairs-editor.ts', find: ' this.owner._suppressClick = true;\n' + ' setTimeout(() => { this.owner._suppressClick = false; }, 0);\n', replace: ' // mutant: the click is free to reach the stage\n', }, { file: 'src/stairs-editor.ts', find: ' @pointerdown=${(event: PointerEvent) => this.pointerDown(event, stair, \'resize\', { sx: handle.sx, sy: handle.sy })}\n' + ' @click=${stop}>\n', replace: ' @pointerdown=${(event: PointerEvent) => this.pointerDown(event, stair, \'resize\', { sx: handle.sx, sy: handle.sy })}>\n', }], }, { id: 'stairs-view-tooltip-ignores-target-state', guard: 'node demo/smoke_stairs.mjs', because: '#676 AC8: the hover tooltip has exactly the link condition; a missing, self, ' + 'deleted or fixed-floor target must never announce a floor to go to.', patches: [{ file: 'src/stairs-view.ts', find: ' const tip = (event: PointerEvent): void => {\n' + ' if (!active) return;\n', replace: ' const tip = (event: PointerEvent): void => {\n' + ' // mutant: every stair announces something\n', }], }, { id: 'stairs-tread-count-depends-on-render-scale', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/stairs.test.mjs', because: '#683 AC6: the equal interval nearest 30 cm is physical; viewport scale may ' + 'change pixels but must not change the number or placement of treads.', patches: [{ file: 'src/stairs.ts', find: ' const count = stairIntervalCount(stair.length * cellCm * GRID_N);\n', replace: ' const count = stairIntervalCount(stair.length * cellCm * GRID_N * NORM_W / scale); // mutant\n', }], }, { id: 'stairs-legacy-save-keeps-implicit-style', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern=legacy test/stairs.test.mjs', because: '#683 AC8: reading a legacy stair stays non-mutating, but its first explicit Save ' + 'must materialize the complete visual quartet so the result no longer follows global defaults.', patches: [{ file: 'src/stairs.ts', find: ' fill_opacity: resolved.fillOpacity,\n', replace: ' fill_opacity: 0, // mutant: one explicit field is not materialized\n', }], }, { id: 'stairs-active-link-loses-pointer-cursor', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern=cursor test/stairs.test.mjs', because: '#683 AC12: only a stair with an active floor target promises navigation with the ' + 'standard pointer cursor; removing it makes the clickable symbol look inert.', patches: [{ file: 'src/styles/plan.styles.ts', find: ' .hp-stair.navigable { cursor: pointer; }\n', replace: ' .hp-stair.navigable { cursor: default; } /* mutant */\n', }], }, { id: 'stairs-broken-targets-become-active', guard: 'node demo/smoke_stairs.mjs', because: '#663 AC7: missing, self and deleted targets must stay visible and repairable, ' + 'but must never navigate or hide their repair warning.', patches: [{ file: 'src/stairs-editor-model.ts', find: " if (!stair.target_space_id) return 'missing';\n" + " if (stair.target_space_id === currentSpaceId) return 'self';\n" + " return spaceIds.has(stair.target_space_id) ? 'active' : 'deleted';\n", replace: " return 'active'; // mutant: every broken target looks navigable\n", }], }, { id: 'stairs-legacy-config-materializes-empty-collection', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern=legacy-no-stairs-config test/stairs.test.mjs', because: '#663 AC11: reading or optimizing a legacy space must not add an empty stairs ' + 'collection to a configuration that never had the feature.', patches: [{ file: 'src/plan-optimizer.ts', find: ' const beforeSpaces = clone(config.spaces || []);\n', replace: ' const beforeSpaces = clone(config.spaces || []);\n' + ' for (const space of config.spaces || []) space.stairs ??= []; // mutant\n', }], }, { id: 'stairs-import-skips-target-space-remap', guard: 'node scripts/backend-test-guard.mjs ' + 'issue_663_full_import_repairs_stair_floor_target_by_exact_space_map ' + 'tests_backend/test_ha_import_export.py', because: '#663 AC11: a full import must remap a stair target through the exact space-id ' + 'map instead of retaining the source document id.', patches: [{ file: 'custom_components/houseplan/import_export.py', find: ' if mapped is not None:\n stair["target_space_id"] = mapped\n', replace: ' if mapped is not None:\n stair["target_space_id"] = target # mutant\n', }], }, { id: 'stairs-backend-allows-251-items', guard: 'node scripts/backend-test-guard.mjs ' + 'issue_663_stair_schema_is_discriminated_bounded_and_forward_compatible ' + 'tests_backend/test_validation.py', because: '#663 AC13: persisted stair collections are bounded at 250 before render and ' + 'support/diagnostic paths can receive them.', patches: [{ file: 'custom_components/houseplan/validation.py', find: ' vol.Optional("stairs"): vol.All([STAIR_SCHEMA], vol.Length(max=MAX_STAIRS)),\n', replace: ' vol.Optional("stairs"): vol.All([STAIR_SCHEMA], vol.Length(max=MAX_STAIRS + 1)), # mutant\n', }], }, { id: 'acl-readonly-group-becomes-writer', guard: 'node scripts/backend-test-guard.mjs ' + 'may_write_honours_explicit_admin_only_false ' + 'tests_backend/test_ha_websocket.py', because: '#626 AC1/AC5: disabling admin_only admits household editors, but HA system-read-only ' + 'must remain unable to mutate config or upload content.', patches: [{ file: 'custom_components/houseplan/auth.py', find: ' return GROUP_ID_READ_ONLY not in group_ids\n', replace: ' return True # mutant: read-only group inherits household writer access\n', }], }, { id: 'acl-plan-catalog-open-to-viewers', guard: 'node scripts/backend-test-guard.mjs ' + 'issue_626_plans_list_refuses_viewer_before_scanning ' + 'tests_backend/test_ha_websocket.py', because: '#626 AC2/AC3: the stored plan filename and usage catalog is maintenance data for ' + 'writers, not part of authenticated View.', patches: [{ file: 'custom_components/houseplan/websocket_api.py', find: ' if not _check_write(hass, connection):\n' + ' connection.send_error(msg["id"], "unauthorized", "Only writers may list plans")\n' + ' return\n' + ' rt = _runtime(hass, connection, msg["id"])\n', replace: ' rt = _runtime(hass, connection, msg["id"]) # mutant: viewers list plan files\n', }], }, { id: 'acl-trail-view-exposes-source-entity', guard: 'node scripts/backend-test-guard.mjs ' + 'issue_626_authenticated_read_acl_matrix_and_trail_projection ' + 'tests_backend/test_ha_websocket.py', because: '#626 AC4: View needs robot coordinates, not private source entity ids embedded at ' + 'arbitrary levels of current and previous route records.', patches: [{ file: 'custom_components/houseplan/websocket_api.py', find: ' if key != "source"\n', replace: ' if True # mutant: expose source entity ids\n', }], }, { id: 'view-conflict-requires-editor-runtime', guard: 'node --test test/config-write-conflict.test.mjs', because: '#612 AC1: View legitimately has no editor runtime, but its background config writes ' + 'can still conflict. Requiring the editor restores the unhandled exception and prevents the ' + 'authoritative reload.', patches: [{ file: 'src/config-write-conflict.ts', find: ' editorRuntime?._cancelPath();\n void reload();', replace: ' editorRuntime!._cancelPath(); // mutant: View has no runtime\n void reload();', }], }, { id: 'readonly-view-syncs-new-devices', guard: 'node demo/smoke_readonly_cold_start.mjs', because: '#612 AC2: a read-only household or kiosk View must never attempt the shared ' + 'known_devices write. Removing the authority guard restores the unauthorized toast for ' + 'every newly discovered HA device.', patches: [{ file: 'src/houseplan-card.ts', find: ' if (!this._norm || !this._loadOk || !this._serverCfg || !this._canEdit) return;', replace: ' if (!this._norm || !this._loadOk || !this._serverCfg) return; // mutant: ignore write authority', }], }, { id: 'discard-confirm-action-icon-falls-back-to-lock', guard: 'node demo/smoke_discard_copy.mjs', because: '#610 AC3/AC4: unsaved-settings discard uses save-off in both places, but other ' + 'warnings retain the lock fallback. Ignoring the per-request action override silently ' + 'restores the misleading open-lock glyph only inside the destructive action button.', patches: [{ file: 'src/hp-confirm.ts', find: " ", replace: " ", }], }, { id: 'range-line-clamps-every-keystroke', guard: 'node demo/smoke_range_line_draft.mjs', because: '#608 AC1/AC5: partial keyboard text must stay local until commit. Restoring the ' + 'old per-input clamp mutates the host draft on the first digit and makes 120 impossible ' + 'to enter as a sequence.', patches: [{ file: 'src/editors/form-kit.ts', find: ' onInput: () => undefined,', replace: ' onInput: (raw) => {\n' + ' const n = Number(raw);\n' + ' if (Number.isFinite(n)) onInput(Math.min(max, Math.max(min, n)));\n' + ' },', }], }, { id: 'ha-form-shell-width-falls-back-to-generic', guard: 'node demo/smoke_ha_form_shell_parity.mjs', because: '#609 AC1: the authentic HA branch must consume the reviewed 560 px form ' + 'width; falling back to HA generic medium width recreates the released/native mismatch', patches: [{ file: 'src/hp-dialog.ts', find: ' --ha-dialog-width-md: 560px;', replace: ' --ha-dialog-width-md: 580px;', }], }, { id: 'ha-form-shell-loses-canvas', guard: 'node demo/smoke_ha_form_shell_parity.mjs', because: '#609 AC3: forms in HA need the same canvas-and-card hierarchy as the reviewed ' + 'native shell; painting the body as the dialog surface erases that hierarchy', patches: [{ file: 'src/styles/dialogs.styles.ts', find: ' background: var(--hpf-canvas, var(--secondary-background-color, color-mix(in srgb, var(--card-background-color, var(--hp-bg, #202126)) 90%, var(--primary-text-color, #000))));', replace: ' background: var(--card-background-color, var(--hp-bg, #202126));', }], }, { id: 'ha-form-shell-mobile-keeps-desktop-inset', guard: 'node demo/smoke_ha_form_shell_parity.mjs', because: '#609 AC4: at 480 px and below the form shell is edge-to-edge; retaining the ' + 'desktop 48 px height inset clips the reviewed mobile surface and footer', patches: [{ file: 'src/hp-dialog.ts', find: ' --ha-dialog-min-height: var(--safe-height, 100dvh);\n' + ' --ha-dialog-max-height: var(--safe-height, 100dvh);', replace: ' --ha-dialog-min-height: calc(var(--safe-height, 100dvh) - 48px);\n' + ' --ha-dialog-max-height: calc(var(--safe-height, 100dvh) - 48px);', }], }, { id: 'attribution-judges-the-head-definition-on-the-base', guard: 'node --test --test-name-pattern="#568" test/mutation-guard-outcome.test.mjs', because: '#568: атрибуция обязана сравнивать подобное с подобным — определение БАЗЫ на ' + 'дереве базы; с определением из головы сломанная своей же правкой запись реестра ' + 'выглядит предсуществующей, и гейт оправдывает автора вместо того, чтобы назвать его', patches: [{ file: 'scripts/mutation-attribution.mjs', find: ' return setupFailureOwner(outcome, run(baseDefinition, { ref: rangeBase }));', replace: ' return setupFailureOwner(outcome, run(mutant, { ref: rangeBase }));', }], }, { id: 'attribution-lets-a-new-witness-off', guard: 'node --test --test-name-pattern="#568" test/mutation-guard-outcome.test.mjs', because: '#568: свидетеля, которого в базе нет, оправдывать нечем — иначе новый мутант, ' + 'не готовящийся к прогону с рождения, проезжает гейт молча', patches: [{ file: 'scripts/mutation-attribution.mjs', find: " return 'introduced';\n }\n log(` атрибуция: определение базы", replace: " return 'pre-existing';\n }\n log(` атрибуция: определение базы", }], }, { id: 'attribution-still-reddens-a-foreign-failure', guard: 'node --test --test-name-pattern="#568" test/mutation-guard-outcome.test.mjs', because: '#568: решение владельца — предсуществующий отказ гейт задачи не красит; если ' + 'итог считать по полному списку, «не красим» остаётся словами', patches: [{ file: 'scripts/mutation-gate.mjs', find: ' return caught === toRun.length - preExisting.length ? 0 : 1;', replace: ' return caught === toRun.length ? 0 : 1;', }], }, { id: 'view-current-space-aria-removed', guard: 'node demo/smoke_household_journeys.mjs', because: '#565 AC1: the active class is visual only; removing aria-current makes the ' + 'open space unknowable to assistive technology even though the tab still looks selected', patches: [{ file: 'src/houseplan-card.ts', find: " aria-current=${this._space === s.id ? 'page' : nothing}", replace: ' aria-current=${nothing}', }], }, { id: 'device-accessible-label-dedup-removed', guard: 'node --test --test-name-pattern="#565 accessible" test/device-presentation.test.mjs', because: '#565 AC3/AC4: state and pulse may localise to the same word; without whole-segment ' + 'deduplication a screen reader announces Alarm twice in both plan cards', patches: [{ file: 'src/device-presentation.ts', find: ' if (seen.has(key)) continue;', replace: ' void seen; // mutant: repeated accessible facts are retained', }], }, { id: 'device-focus-tooltip-handler-removed', guard: 'node demo/smoke_household_journeys.mjs', because: '#565 AC5: the visible focus ring alone does not give a sighted keyboard user the ' + 'device name and metrics shown to a mouse user', patches: [{ file: 'src/houseplan-card.ts', find: ' @focus=${(e: FocusEvent) => this._showDeviceFocusTip(e, d)}', replace: ' @focus=${nothing}', }], }, { id: 'device-focus-tooltip-blur-cleanup-removed', guard: 'node demo/smoke_household_journeys.mjs', because: '#565 AC6: a focus-owned tooltip must not remain after keyboard focus leaves the ' + 'marker; stale content can otherwise describe the wrong device', patches: [{ file: 'src/houseplan-card.ts', find: ' private _hideDeviceFocusTip(deviceId: string): void { this._liveRt?.deviceBlur(deviceId); }', replace: ' private _hideDeviceFocusTip(deviceId: string): void { void deviceId; }', }], }, { id: 'device-focus-tooltip-room-hover-overwrites', guard: 'node demo/smoke_household_journeys.mjs', because: '#565 AC6/r1 M1: moving a mouse over the room must not erase a keyboard-owned ' + 'device tooltip while focus remains on that device', patches: [{ file: 'src/houseplan-card.ts', find: " if (room && this._liveRt?.deviceFocusActive()) return;", replace: ' void this._liveRt; // mutant: room hover overwrites the focus tooltip', }], }, { id: 'device-pointer-leave-clears-focus-fallback', guard: 'node demo/smoke_household_journeys.mjs', because: '#565 AC6/r1 M1: pointerleave after a temporary device hover must restore the ' + 'still-focused keyboard tooltip instead of falling through to the destructive fallback', patches: [{ file: 'src/houseplan-card.ts', find: ' private _clearPointerHover(): void { this._liveRt ? this._liveRt.pointerLeave() : this._clearTransientHover(); }', replace: ' private _clearPointerHover(): void { this._liveRt?.pointerLeave() ?? this._clearTransientHover(); }', }], }, { id: 'invariants-blame-every-stale-position', guard: 'node --test --test-name-pattern="#566" test/model-invariants.test.mjs', because: '#566: продукт СОЗНАТЕЛЬНО хранит позицию, чей владелец жив, а пространство ' + 'удалено; объявляя это нарушением, инварианты краснеют на законном конфиге сразу ' + 'после Optimize — и следующий разбор начнётся с ложного сигнала', patches: [{ file: 'scripts/model-invariants.mjs', find: " if (owner === 'absent') {", replace: " if (owner === 'absent' || true) {", }], }, { id: 'invariants-claim-proof-for-an-unknown-owner', guard: 'node --test --test-name-pattern="#566" test/model-invariants.test.mjs', because: '#566 r1: ключ, который ни во что не резолвится, и живой маркер — разные ' + 'состояния; общая формулировка «владелец жив» заявляет доказанность там, где её нет, ' + 'и следующий разбор поверит записи вместо проверки', patches: [{ file: 'scripts/model-invariants.mjs', find: " : activeMarkerIds.has(key) ? 'live'\n : 'unverified';", replace: " : 'live';", }], }, { id: 'invariants-forgive-a-vanished-position-owner', guard: 'node --test --test-name-pattern="#566" test/model-invariants.test.mjs', because: '#566: обратная сторона того же правила — запись, чей владелец отсутствует по ' + 'самой конфигурации, обязана остаться нарушением, иначе сужение превращается в ' + 'отключение проверки', patches: [{ file: 'scripts/model-invariants.mjs', // Файл на JS, поэтому литеральная ложь здесь безопасна: сужения типов, // которые ломает статически мёртвая ветка в .ts (#568), тут нет. find: " : removedMarkerIds.has(key) ? 'absent'", replace: " : false ? 'absent'", }], }, { id: 'dense-device-hit-falls-back-to-input-order', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/device-hit-owner.test.mjs', because: '#564: при перекрытии целей владелец выбирается по экранной геометрии, а не ' + 'по порядку DOM/массива; иначе сосед снова крадёт hover, click и drag', patches: [{ file: 'src/device-hit-owner.ts', find: ' if (distance < bestDistance - EPSILON\n' + ' || (Math.abs(distance - bestDistance) <= EPSILON\n' + ' && (best === null || candidate.id < best.id))) {', replace: ' if (best === null) {', }], }, { id: 'dense-device-hit-browser-skips-painted-priority', guard: 'node demo/smoke_device_hit_capsules.mjs', because: '#564 r1 M1: real Icon/Text/Double/legacy DOM geometry in every cardinal ' + 'direction must beat an earlier neighbour whose invisible 44 px floor overlaps ' + 'the painted point; a synthetic rectangle-only proof is insufficient', patches: [{ file: 'src/device-hit-owner.ts', find: ' if (painted.length) return nearest(painted, point);', replace: ' if (false && painted.length) return nearest(painted, point);', }], }, { id: 'device-hit-scroll-observer-disabled', guard: 'node demo/smoke_device_hit_capsules.mjs', because: '#613 AC1/AC2: scroll inside a Home Assistant shadow-root ancestor must invalidate ' + 'the client-coordinate hit index; document-level observation alone cannot see that event', patches: [{ file: 'src/device-hit-owner.ts', find: " source.addEventListener('scroll', handler, { passive: true });", replace: " if (false) source.addEventListener('scroll', handler, { passive: true });", }], }, { id: 'radar-sources-compared-as-text', guard: 'node --test --test-name-pattern="#567" test/radar-editor.test.mjs', because: '#567: порядок ключей `sources` меняет сам билдер, поэтому текстовое сравнение ' + 'ложно отвечает «источники изменились» и радар молча теряет refs и rms_cm ' + 'двухточечной калибровки при первом же обычном сохранении', patches: [{ file: 'src/radar-editor.ts', find: ' && canonicalSources(original.sources) === canonicalSources(sources)', replace: ' && JSON.stringify(original.sources) === JSON.stringify(sources)', }], }, { id: 'household-marker-drops-keyboard-reach', guard: 'node demo/smoke_household_journeys.mjs', because: '#560 J4: маркер с действием обязан доставаться клавиатурой — без tabindex он ' + 'остаётся нажимаемым мышью, и обычный прогон этого не замечает', patches: [{ file: 'src/houseplan-card.ts', find: " tabindex=${interactive ? '0' : nothing}\n aria-label=${deviceAriaLabel}", replace: ' aria-label=${deviceAriaLabel}', }], }, { id: 'household-enter-stops-acting', guard: 'node demo/smoke_household_journeys.mjs', because: '#560 J4: Enter и Space на маркере обязаны вызывать действие; фокус и роль при ' + 'этом остаются на месте, поэтому дефект виден только вызовом сервиса', patches: [{ file: 'src/houseplan-card.ts', find: " if (ev.key !== 'Enter' && ev.key !== ' ') return;", replace: ' return;', }], }, { id: 'corpus-loses-its-short-edge', guard: 'node --test --test-name-pattern="c1-short-edges" test/geometry-corpus.test.mjs', because: '#560: фикстура, потерявшая своё условие, — это зелёный прогон, который ничего ' + 'не проверяет; корпус обязан замечать такую потерю, а не считать дальше', patches: [{ file: 'test/fixtures/560-corpus/c1-short-edges.json', find: ' 0.24166666666666667,\n 0.15833333333333333\n ],\n [\n 0.24166666666666667,', replace: ' 0.2,\n 0.15833333333333333\n ],\n [\n 0.2,', }], }, { id: 'optimize-reports-work-it-did-not-do', guard: 'node --test --test-name-pattern="импорт" test/geometry-corpus.test.mjs', because: '#477/#560: приведённый план Optimize трогать не должен; вечное changed=true ' + 'заново пишет конфиг, бьёт rev и множит записи истории на каждом нажатии', patches: [{ file: 'src/plan-optimizer.ts', find: ' const changed = JSON.stringify(persistedConfig) !== original\n || JSON.stringify(persistedLayout) !== originalLayout;', replace: ' const changed = true;', }], }, { id: 'summary-hide-unmounts-before-animation', guard: 'node demo/smoke_summary_panel_polish.mjs', because: '#505 AC3: the outgoing panel must stay mounted and inert until its measured exit animation finishes', patches: [{ file: 'src/summary-panel-runtime-loaded.ts', find: ' if (!this.presentation.mounted) return nothing;', replace: ' if (!this.presentation.mounted || !this.local.show) return nothing;', }], }, { id: 'summary-dialog-loses-wide-shell', guard: 'node demo/smoke_summary_panel_polish.mjs', because: '#505 AC5: widening only inner fields must not pass while the native/HA dialog shell stays narrow', patches: [{ file: 'src/summary-panel-editor.ts', find: " .title=${t('summary.settings')} wide", replace: " .title=${t('summary.settings')}", }], }, { id: 'summary-mobile-ignores-local-off', guard: 'node demo/smoke_summary_panel_polish.mjs', because: '#505 AC8: the real mobile checkbox must be disabled under local off without losing its saved value', patches: [{ file: 'src/summary-panel-editor.ts', find: ' .checked=${dialog.draft.show_on_mobile} ?disabled=${!dialog.localShow || dialog.busy}', replace: ' .checked=${dialog.draft.show_on_mobile} ?disabled=${dialog.busy}', }], }, { id: 'summary-masonry-identity-uses-visual-dom-path', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="full Masonry reload uses canonical" ' + 'test/summary-panel.test.mjs', because: '#561 AC1/AC7: a full Masonry reload must derive each preference slot from ' + 'the canonical cards array, never the responsive visual-column DOM path', patches: [{ file: 'src/summary-panel-identity.ts', find: " const slot = `masonry-v2:${cardIndex}${suffix ? `/${suffix}` : ''}`;", replace: ' const slot = structuralPath(start); // mutant: visual DOM path becomes persistent identity', }], }, { id: 'summary-animation-stale-completion-unguarded', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="reversal snapshots current progress" ' + 'test/summary-panel-presentation.test.mjs', because: '#505 AC3/AC4: a cancelled animation promise or timeout must not settle or remove ' + 'the newer run after an in-place reversal; both completion ownership checks are removed', patches: [{ file: 'src/summary-panel-presentation.ts', find: ' if (token !== this.generation || animation !== this.animation) return;', replace: ' void token; // mutant: stale completion can settle the current run', }], }, { id: 'summary-animation-reset-keeps-presentation', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="reset releases resources and stale callbacks" ' + 'test/summary-panel-presentation.test.mjs', because: '#505 AC4: an identity/route/disconnect reset must hide the old presentation, ' + 'release its animation/timer/listener and prevent a late repaint into the new context', patches: [{ file: 'src/summary-panel-presentation.ts', find: ' public reset(): void {\n this.settle(false);\n this.side = null;', replace: ' public reset(): void {\n this.side = null; // mutant: old presentation survives reset', }], }, { id: 'summary-animation-ignores-reduced-motion', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="reduced motion settles both directions" ' + 'test/summary-panel-presentation.test.mjs', because: '#505 AC4: an already enabled reduced-motion preference must settle entering ' + 'and exiting immediately without starting a WAAPI animation or delayed removal timer', patches: [{ file: 'src/summary-panel-presentation.ts', find: " if (!view || media?.matches || typeof element.animate !== 'function') {", replace: " if (!view || typeof element.animate !== 'function') {", }], }, { id: 'room-temperature-blank-becomes-zero', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="room temperature draft" test/space-dialog.test.mjs', because: 'a blank per-room bound means inheritance and must never become the explicit numeric value zero', patches: [{ file: 'src/space-dialog.ts', find: " if (String(raw ?? '').trim() === '') return { valid: true, value: null };", replace: " if (String(raw ?? '').trim() === '') return { valid: true, value: 0 };", }], }, { id: 'room-temperature-min-inherits-wrong-side', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="room temperature range" test/logic.test.mjs', because: 'each absent room threshold must inherit the matching space side before the effective pair is normalized', patches: [{ file: 'src/logic.ts', find: ' const rawMin = finiteOr(room?.settings?.temp_min, inheritedMin);', replace: ' const rawMin = finiteOr(room?.settings?.temp_min, inheritedMax);', }], }, { id: 'pdf-shared-wall-twice', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="shared wall architecture" test/pdf-scene.test.mjs', because: 'a shared wall must be one canonical physical component on the printed sheet', patches: [{ file: 'src/pdf/pdf-scene.ts', find: " for (const component of built.geometry.components) commands.push({\n" + " kind: 'path', rings: geometryAllRings(component.geom).map((ring) => ring.map(pt)),\n" + ' fill: WALL, stroke: INK, width: 0.25 * MM,\n' + ' hatch: { lines: hatchLines, stroke: INK, width: 0.18 * MM },\n' + ' });', replace: " for (const component of [...built.geometry.components, ...built.geometry.components]) commands.push({\n" + " kind: 'path', rings: geometryAllRings(component.geom).map((ring) => ring.map(pt)),\n" + ' fill: WALL, stroke: INK, width: 0.25 * MM,\n' + ' hatch: { lines: hatchLines, stroke: INK, width: 0.18 * MM },\n' + ' });', }], }, { id: 'pdf-dimensions-ignore-toggle', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="dimensions switch removes" test/pdf-scene.test.mjs', because: 'turning dimensions off must remove the external dimension chain as well as room labels', patches: [{ file: 'src/pdf/pdf-scene.ts', find: ' if (input.options.dimensions && built.geometry.roomGeom) {', replace: ' if (built.geometry.roomGeom) {', }], }, { id: 'pdf-room-edge-dropped', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="#530: a square house" test/pdf-scene.test.mjs', because: 'since #530 there is no callout column to catch a dropped edge: a value that is ' + 'not printed beside its wall is not printed anywhere, so the dedupe must never quietly ' + 'lose an edge', patches: [{ file: 'src/pdf/pdf-scene.ts', find: ' const edges = dedupeOppositeDimensionEdges(rawEdges,\n' + ' { ring: contour, epsilon, score: placementScore });', replace: ' const edges = dedupeOppositeDimensionEdges(rawEdges,\n' + ' { ring: contour, epsilon, score: placementScore }).slice(0, -1);', }], }, { id: 'pdf-restores-dimension-callouts', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="#530" test/pdf-scene.test.mjs', because: 'the numbered column beside the plan cost a whole scale step — 124 pt of the upright ' + 'field — and turned the sheet sideways: a ten-metre house printed 1:100 landscape on a ' + 'quarter of the field instead of 1:75 upright (#530 AC1)', patches: [{ file: 'src/pdf/pdf-scene.ts', find: ' if (!placed) continue;', replace: ' if (!placed) {\n' + ' for (const edge of group) commands.push({ kind: \'text\',\n' + ' x: pt(edge.mid)[0] + edge.inwardNormal[0] * 4 * MM,\n' + ' y: pt(edge.mid)[1] + edge.inwardNormal[1] * 4 * MM,\n' + ' text: `R${occupied.length}`, size: 6, align: \'center\' });\n' + ' continue;\n' + ' }', }], }, { id: 'pdf-keeps-large-in-plan-labels', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="#530" test/pdf-scene.test.mjs', because: 'in-plan labels do not scale with the drawing: at the old size they crowd the larger ' + 'plan instead of reading better, and they take the room the architecture needs (#530 AC2)', patches: [{ file: 'src/pdf/pdf-scene.ts', find: 'const DIMENSION_SIZE = 5.25;', replace: 'const DIMENSION_SIZE = 7;', }], }, { id: 'pdf-duplicate-normalization-too-late', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="1 mm physical duplicate" test/pdf-dimensions.test.mjs', because: '1 mm duplicate normalization must run before every collinearity pass or a tiny seam can become a false chord', patches: [{ file: 'src/pdf/pdf-dimensions.ts', find: ' const duplicatesChanged = collapseAdjacentDuplicates(ring, epsilon);', replace: ' const duplicatesChanged = false;', }], }, { id: 'pdf-diagonal-dimension-restored', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="canonical 0.25 degree" test/pdf-dimensions.test.mjs', because: 'true diagonal room edges must not be projected into plausible-looking horizontal or vertical dimensions', patches: [{ file: 'src/pdf/pdf-dimensions.ts', find: ' if (!(major > 0) || minor / major > NEAR_AXIS_MAX_SLOPE) return null;', replace: ' if (!(major > 0)) return null;', }], }, { id: 'pdf-equal-text-deduped', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="opposite dedupe preserves" test/pdf-dimensions.test.mjs', because: 'formatted text is not geometric identity: equal spans on another axis or contour must survive', patches: [{ file: 'src/pdf/pdf-dimensions.ts', find: ' return edges.filter((edge) => !removed.has(edge));', replace: ' return edges.filter((edge, index) => !removed.has(edge) ' + '&& edges.findIndex((candidate) => candidate.text === edge.text) === index);', }], }, { id: 'pdf-virtual-wall-solid', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="includes architecture" test/pdf-scene.test.mjs', because: 'zero-thickness walls are identified by the dashed print convention', patches: [{ file: 'src/pdf/pdf-scene.ts', find: ' stroke: INK, width: 0.35 * MM, dash: [3 * MM, 2 * MM],', replace: ' stroke: INK, width: 0.35 * MM,', }], }, { id: 'pdf-zero-wall-gets-solid-fallback', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="includes architecture" test/pdf-scene.test.mjs', because: 'a valid zero-wall-only space must remain dashed instead of receiving a solid room outline', patches: [{ file: 'src/pdf/pdf-scene.ts', find: " if (built.geometry.status === 'failed-core') for (const room of input.space.rooms) {", replace: ' if (!built.geometry.components.length) for (const room of input.space.rooms) {', }], }, { id: 'pdf-wall-material-not-grey', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="physical bodies use exact grey" test/pdf-scene.test.mjs', because: 'every physical wall, partition and column must keep the exact reviewed #7f7f7f print fill', patches: [{ file: 'src/pdf/pdf-scene.ts', find: 'const WALL: readonly [number, number, number] = [127 / 255, 127 / 255, 127 / 255];', replace: 'const WALL: readonly [number, number, number] = [0.72, 0.72, 0.72];', }], }, { id: 'pdf-wall-hatch-removed', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="physical bodies use exact grey" test/pdf-scene.test.mjs', because: 'wall hatch is part of the exported architectural material, not an optional writer decoration', patches: [{ file: 'src/pdf/pdf-scene.ts', find: ' hatch: { lines: hatchLines, stroke: INK, width: 0.18 * MM },\n', replace: '', }], }, { id: 'pdf-hatch-loses-evenodd-hole', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="hatched paths use an even-odd clip" test/pdf-writer.test.mjs', because: 'non-even-odd hatch clipping paints through compound opening holes', patches: [{ file: 'src/pdf/pdf-writer.ts', find: " out.push('W*', 'n', `${color(command.hatch.stroke)} RG ${fmt(command.hatch.width)} w`);", replace: " out.push('W', 'n', `${color(command.hatch.stroke)} RG ${fmt(command.hatch.width)} w`);", }], }, { id: 'pdf-font-no-tounicode', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="extractable Cyrillic" test/pdf-writer.test.mjs', because: 'embedded Cyrillic text must remain extractable Unicode, not only visible glyph ids', patches: [{ file: 'src/pdf/pdf-writer.ts', find: ' /ToUnicode ${toUnicode} 0 R', replace: '', }], }, { id: 'pdf-backdrop-over-geometry', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="backdrop is below" test/pdf-scene.test.mjs', because: 'a raster backdrop must be emitted before physical architecture in the PDF content stream', patches: [{ file: 'src/pdf/pdf-scene.ts', find: ' for (const raster of input.rasters || []) commands.push({\n' + " kind: 'image', imageId: raster.id, ...pt([raster.x, raster.y]).reduce((o, value, index) =>\n" + " ({ ...o, [index ? 'y' : 'x']: value }), {} as { x: number; y: number }),\n" + ' width: raster.drawWidth * pointPerUnit, height: raster.drawHeight * pointPerUnit,\n' + ' angle: raster.angle, opacity: raster.opacity,\n' + ' });\n', replace: '', }, { file: 'src/pdf/pdf-scene.ts', find: " for (const component of built.geometry.components) commands.push({\n" + " kind: 'path', rings: geometryAllRings(component.geom).map((ring) => ring.map(pt)),\n" + ' fill: WALL, stroke: INK, width: 0.25 * MM,\n' + ' hatch: { lines: hatchLines, stroke: INK, width: 0.18 * MM },\n' + ' });', replace: " for (const component of built.geometry.components) commands.push({\n" + " kind: 'path', rings: geometryAllRings(component.geom).map((ring) => ring.map(pt)),\n" + ' fill: WALL, stroke: INK, width: 0.25 * MM,\n' + ' hatch: { lines: hatchLines, stroke: INK, width: 0.18 * MM },\n' + ' });\n' + ' for (const raster of input.rasters || []) commands.push({\n' + " kind: 'image', imageId: raster.id, ...pt([raster.x, raster.y]).reduce((o, value, index) =>\n" + " ({ ...o, [index ? 'y' : 'x']: value }), {} as { x: number; y: number }),\n" + ' width: raster.drawWidth * pointPerUnit, height: raster.drawHeight * pointPerUnit,\n' + ' angle: raster.angle, opacity: raster.opacity,\n' + ' });', }], }, { id: 'pdf-devices-leak', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="devices never enter" test/pdf-scene.test.mjs', because: 'the architectural PDF must not serialize HA markers or live device identity', patches: [{ file: 'src/pdf/pdf-scene.ts', find: ' // Architectural export deliberately has no marker/device projection pass.', replace: " for (const marker of input.config.markers || []) commands.push({\n" + " kind: 'text', x: margin, y: fieldTop, text: marker.id, size: 6,\n" + ' });', }], }, { id: 'pdf-scale-not-standard', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="standard scale" test/pdf-scene.test.mjs', because: 'the printed scale must always come from the approved architectural series', patches: [{ file: 'src/pdf/pdf-scene.ts', find: ' for (const scale of PDF_SCALE_SERIES) {', replace: ' for (const scale of [42]) {', }], }, { id: 'pdf-layout-uses-raw-aspect', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="actual annotated scene bbox" test/pdf-scene.test.mjs', because: 'orientation must compare the complete annotated scene, not pick one page from the raw architecture aspect ratio', patches: [{ file: 'src/pdf/pdf-scene.ts', find: ' const candidates = [false, true]\n' + ' .filter((landscape) => rawArchitectureCanFit(prepared, scale, landscape))', replace: ' const candidates = [pdfBoundsWidth(prepared.bounds) > pdfBoundsHeight(prepared.bounds)]\n' + ' .filter((landscape) => rawArchitectureCanFit(prepared, scale, landscape))', }], }, { id: 'pdf-compass-reverts-to-line-arrow', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="integrates the vector compass" test/pdf-scene.test.mjs', because: 'the licensed two-path compass must stay in the PDF scene instead of regressing to the old line arrow', patches: [{ file: 'src/pdf/pdf-scene.ts', find: " commands.push({ kind: 'vector',\n" + ' ops: pdfCompassOps({ centerX: nx, centerY: ny, size: 11 * MM, northDeg: north }),\n' + " fill: INK, width: 0, fillRule: 'evenodd' });", replace: " commands.push({ kind: 'line', points: [[nx, ny], [nx, ny - 11 * MM]],\n" + ' stroke: INK, width: 0.5 * MM });', }], }, { id: 'pdf-architectural-legend-restored', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="never restores the architectural legend" test/pdf-scene.test.mjs', because: 'the removed wall-door-window legend must not consume PDF footer space again', patches: [{ file: 'src/pdf/pdf-scene.ts', find: " commands.push({ kind: 'text', x: margin, y: footerY,\n" + " text: input.t('pdf.scale', { n: scale }), size: 8 });", replace: " commands.push({ kind: 'text', x: margin, y: footerY,\n" + " text: input.t('pdf.scale', { n: scale }), size: 8 });\n" + " commands.push({ kind: 'text', x: pageWidth / 2, y: footerY,\n" + " text: 'wall · door · window', size: 7, align: 'center' });", }], }, { id: 'pdf-heavy-scene-prepared-twice', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="current 20-room large-house" test/pdf-scene.test.mjs', because: 'orientation and scale candidates must reuse one prepared geometry/cache snapshot', patches: [{ file: 'src/pdf/pdf-scene.ts', find: ' const prepared = preparePdfScene(input);', replace: ' preparePdfScene(input);\n const prepared = preparePdfScene(input);', }], }, { id: 'pdf-dimension-lane-splits-group', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="whole dimension lane" test/pdf-scene.test.mjs', because: 'one collision must move the complete same-normal dimension group without per-edge tangent jitter', patches: [{ file: 'src/pdf/pdf-scene.ts', find: ' provisional, base[0], base[1], edge.inwardNormal, (1 + lane) * MM,', replace: ' provisional, base[0], base[1], edge.inwardNormal, ' + '(1 + (edge === group[0] ? lane : 0)) * MM,', }], }, { id: 'pdf-parallel-facade-shares-lane', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="parallel steps" test/pdf-dimensions.test.mjs', because: 'parallel facade steps at different normal coordinates need independent collision lanes', patches: [{ file: 'src/pdf/pdf-dimensions.ts', find: ' const group = groups.find((candidate) => candidate.axis === edge.axis\n' + ' && candidate.normalSign === edge.normalSign\n' + ' && Math.abs(candidate.normalCoordinate - edge.normalCoordinate) <= epsilon);', replace: ' const group = groups.find((candidate) => candidate.axis === edge.axis\n' + ' && candidate.normalSign === edge.normalSign);', }], }, { id: 'pdf-raster-prefilter-ignores-rotation', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="rotated raster bounds" test/pdf-scene.test.mjs', because: 'the cheap scale prefilter must use the same rotated raster extent as the real scene', patches: [{ file: 'src/pdf/pdf-scene.ts', find: ' for (const raster of input.rasters || []) allBounds.push(rotatedRectRing(\n' + ' raster.x, raster.y, raster.drawWidth, raster.drawHeight, raster.angle,\n' + ' ));', replace: ' for (const raster of input.rasters || []) allBounds.push([\n' + ' [raster.x, raster.y], [raster.x + raster.drawWidth, raster.y + raster.drawHeight],\n' + ' ]);', }], }, { id: 'pdf-text-box-misses-thin-diagonal', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="thin diagonal" test/pdf-collision.test.mjs', because: 'dimension labels need exact edge intersection, not a sparse point sample', patches: [{ file: 'src/pdf/pdf-collision.ts', find: ' if (rings.some((ring) => ring.some((point) => finitePoint(point) && pointInBox(point, box)))) return true;\n' + ' return forEachRingSegment(rings, (a, b) => corners.some((corner, index) =>\n' + ' segmentIntersection(a, b, corner, corners[(index + 1) % corners.length]) !== null));', replace: ' if (rings.some((ring) => ring.some((point) => finitePoint(point) && pointInBox(point, box)))) return true;\n' + ' return false;', }], }, { id: 'pdf-label-clearance-is-not-inflated', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="collision boxes expand" test/pdf-collision.test.mjs', because: 'dimension labels need the specified paper-space clearance from every neighbouring wall', patches: [{ file: 'src/pdf/pdf-collision.ts', find: ' const amount = Number.isFinite(clearance) && clearance >= 0 ? clearance : 0;', replace: ' const amount = 0;', }], }, { id: 'pdf-extension-start-boundary-asymmetric', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="allowed extension start" test/pdf-collision.test.mjs', because: 'an allowed extension must leave every outer or opening face independent of winding', patches: [{ file: 'src/pdf/pdf-collision.ts', find: ' if ((isSolid(start) && !(options.allowStartBoundary && startOnBoundary))\n' + ' || isSolid(midpoint) || isSolid(end)) return true;', replace: ' if (isSolid(start) || isSolid(midpoint) || isSolid(end)) return true;', }], }, { id: 'pdf-own-exit-prefix-disabled', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="external dimension extension" test/pdf-collision.test.mjs', because: 'a stepped-facade extension must be allowed to leave the contiguous solid prefix at its own source corner', patches: [{ file: 'src/pdf/pdf-collision.ts', find: ' if (options.allowStartExit) {', replace: ' if (false && options.allowStartExit) {', }], }, { id: 'pdf-post-exit-collision-ignored', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="external dimension extension" test/pdf-collision.test.mjs', because: 'after an extension reaches free space every later wall crossing or tangent contact must still reject that lane', patches: [{ file: 'src/pdf/pdf-collision.ts', find: ' return hits.some((hit) => hit.t > exitT! + parameterEpsilon\n' + ' || hit.endT > exitT! + parameterEpsilon);', replace: ' return false;', }], }, { id: 'pdf-near-axis-extension-starts-at-projection', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="thick near-axis outer face" test/pdf-scene.test.mjs', because: 'near-axis dimension extensions must start on the real thick-wall face, not inside it', patches: [{ file: 'src/pdf/pdf-scene.ts', find: ' const a = pt(edge.sourceA), b = pt(edge.sourceB);', replace: ' const a = pt(edge.a), b = pt(edge.b);', }], }, { id: 'pdf-opposite-dedupe-ignores-safe-side', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="opposite dedupe keeps" test/pdf-scene.test.mjs', because: 'a paired outer dimension must retain the side with a collision-free lane', patches: [{ file: 'src/pdf/pdf-scene.ts', find: ' const firstSafeLane = Array.from({ length: 21 }, (_, index) => index * 4)\n' + ' .find((lane) => !externalPlacementTouchesArchitecture(\n' + ' externalPlacement(edge, outward, lane),\n' + ' ));', replace: ' const firstSafeLane = 0;', }], }, { id: 'pdf-rectangle-restores-unsafe-label', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="blocked rectangular" test/pdf-scene.test.mjs', because: 'a dimension with no valid lane must not be printed through the room: since #530 the ' + 'rectangular rule is the general one, so the only alternative to a clean lane is silence', patches: [{ file: 'src/pdf/pdf-scene.ts', find: ' // через стену, имя или площадь.', replace: ' if (!placed) { for (const edge of group) commands.push({ kind: \'text\',\n' + ' x: pt(edge.mid)[0], y: pt(edge.mid)[1], text: edge.text,\n' + ' size: DIMENSION_SIZE, angle: edge.angle, align: \'center\' }); }', }], }, { id: 'pdf-overflow-returns-clipped-page', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="fails closed" test/pdf-scene.test.mjs', because: 'architecture that does not fit at any representable denominator must fail instead of ' + 'returning a clipped A4 page; since #530 this is the only fail-closed case left, because a ' + 'label that has no room is dropped rather than making the whole sheet unprintable', patches: [{ file: 'src/pdf/pdf-scene.ts', find: " if (!fittingScale) throw new Error('pdf.failed');", replace: ' if (!fittingScale) return lastCandidates[0].page;', }], }, { id: 'pdf-page-hatch-phase-translated', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="page-anchored hatch phase" test/pdf-layout.test.mjs', because: 'scene centering must move physical rings while leaving all components on one page-origin hatch phase', patches: [{ file: 'src/pdf/pdf-layout.ts', find: " if (command.kind === 'path') return {\n" + ' ...command,\n' + ' rings: command.rings.map((ring) => ring.map(([x, y]) => [x + dx, y + dy] as const)),\n' + ' };', replace: " if (command.kind === 'path') return {\n" + ' ...command,\n' + ' rings: command.rings.map((ring) => ring.map(([x, y]) => [x + dx, y + dy] as const)),\n' + ' hatch: command.hatch ? { ...command.hatch,\n' + ' lines: command.hatch.lines.map((line) =>\n' + ' line.map(([x, y]) => [x + dx, y + dy] as const)),\n' + ' } : undefined,\n' + ' };', }], }, { id: 'resource-docs-flatten-current-yaml', // Свежесть скриншотов — предупреждение (#479): мутант проверяет YAML-сниппет // README, а не отпечаток кадров; строгий режим красил чистый прогон на любом // src-диффе без пересъёмки (run 2793, #496). guard: 'node scripts/check-docs.mjs --screenshots=warn', because: 'the supported HA 2026.2+ resource snippet must stay nested under lovelace; ' + 'a plausible-looking top-level resources block is the user-facing defect from #462 AC1', patches: [{ file: 'README.md', find: 'lovelace:\n resource_mode: yaml\n resources:', replace: 'resource_mode: yaml\nresources:', }], }, { id: 'frontend-registration-skips-retry', guard: 'python3 -m pytest tests_backend/test_ha_frontend_registration.py -q -p no:cacheprovider ' + '-k "retry_waits_one_second"', because: 'a temporarily unavailable Lovelace registry must receive the one lifecycle-bound ' + 'recovery attempt promised by #462 AC3', patches: [{ file: 'custom_components/houseplan/frontend_registration.py', find: ' _schedule_retry(hass, entry, state)', replace: ' pass # mutant: recovery retry removed', }], }, { id: 'frontend-registration-retries-without-delay', guard: 'python3 -m pytest tests_backend/test_ha_frontend_registration.py -q -p no:cacheprovider ' + '-k "retry_waits_one_second"', because: 'even an already-running HA must retain the fixed cancellable delay instead of ' + 'repeating a transient registry failure in the same tick (#462 AC3)', patches: [{ file: 'custom_components/houseplan/frontend_registration.py', find: ' state._cancel_timer = async_call_later(\n' + ' hass, FRONTEND_RETRY_DELAY_SECONDS, _after_delay\n' + ' )', replace: ' _after_delay() # mutant: retry runs without the fixed lifecycle delay', }], }, { id: 'frontend-registration-is-not-unload-bound', guard: 'python3 -m pytest tests_backend/test_ha_frontend_registration.py -q -p no:cacheprovider ' + '-k "retry_lifecycle_handles_are_cancelled"', because: 'the start listener, delay and running retry belong to the config-entry lifecycle; ' + 'otherwise unload can resurrect frontend side effects (#462 AC3)', patches: [{ file: 'custom_components/houseplan/frontend_registration.py', find: ' entry.async_on_unload(state.cancel)', replace: ' pass # mutant: retry lifecycle detached from config-entry unload', }], }, { id: 'frontend-reload-notice-forgets-persisted-flag', guard: 'python3 -m pytest tests_backend/test_ha_frontend_registration.py -q -p no:cacheprovider ' + '-k "reload_notice_is_localized_and_persisted_once"', because: 'the first-install hard-reload notice must remain one-shot across retry, reload and ' + 'future versions rather than reappearing forever (#462 AC4)', patches: [{ file: 'custom_components/houseplan/frontend_registration.py', find: ' data={**entry.data, FRONTEND_RELOAD_NOTICE_DATA_KEY: True},', replace: ' data={**entry.data}, # mutant: one-shot flag is not persisted', }], }, { id: 'panel-registers-wrong-route', guard: 'python3 -m pytest tests_backend/test_ha_panel_registration.py -q -p no:cacheprovider ' + '-k "registers_exact_public_panel_contract"', because: 'the application must own exactly /houseplan; registering another route breaks the ' + 'stable entry point while a superficially valid panel still exists (#486 AC1)', patches: [{ file: 'custom_components/houseplan/panel_registration.py', find: ' frontend_url_path=PANEL_URL_PATH,', replace: ' frontend_url_path="houseplan-mutant",', }], }, { id: 'panel-registers-card-static-url', guard: 'python3 -m pytest tests_backend/test_ha_panel_registration.py -q -p no:cacheprovider ' + '-k "registers_exact_public_panel_contract"', because: 'the panel has a separate stable entry and static route; silently reusing the card URL ' + 'would load no houseplan-panel custom element (#486 AC1)', patches: [{ file: 'custom_components/houseplan/panel_registration.py', find: ' hass, PANEL_FRONTEND_URL, panel_path\n', replace: ' hass, "/houseplan_files/houseplan-card.js", panel_path\n', }], }, { id: 'panel-module-url-loses-version', guard: 'python3 -m pytest tests_backend/test_ha_panel_registration.py -q -p no:cacheprovider ' + '-k "registers_exact_public_panel_contract"', because: 'the panel module must share the integration version cache boundary; an unversioned ' + 'entry can retain an obsolete shell after update (#486 AC1)', patches: [{ file: 'custom_components/houseplan/panel_registration.py', find: ' module_url=module_url,', replace: ' module_url=PANEL_FRONTEND_URL,', }], }, { id: 'panel-becomes-admin-only', guard: 'python3 -m pytest tests_backend/test_ha_panel_registration.py -q -p no:cacheprovider ' + '-k "registers_exact_public_panel_contract or visible_to_admin_and_read_only"', because: 'sidebar visibility is not write authorization; require_admin=True would hide the ' + 'read-only View from household members promised by #486 AC1/AC9', patches: [{ file: 'custom_components/houseplan/panel_registration.py', find: ' require_admin=False,', replace: ' require_admin=True,', }], }, { id: 'panel-bypasses-panel-custom-api', guard: 'python3 -m pytest tests_backend/test_ha_panel_registration.py -q -p no:cacheprovider ' + '-k "registers_exact_public_panel_contract"', because: 'only panel_custom.async_register_panel supplies the supported custom-element/module ' + 'contract across the minimum HA version (#486 AC1/AC14)', patches: [{ file: 'custom_components/houseplan/panel_registration.py', find: ' await panel_custom.async_register_panel(', replace: ' await frontend.async_register_built_in_panel(', }], }, { id: 'panel-static-legacy-boolean-claims-both-urls', guard: 'python3 -m pytest tests_backend/test_ha_frontend_registration.py ' + 'tests_backend/test_ha_panel_registration.py -q -p no:cacheprovider ' + '-k "legacy_card_boolean_becomes_per_url"', because: 'the pre-panel boolean proves only the card route; treating it as both routes leaves ' + 'the panel registry pointing at an unserved module after reload (#486 AC1/AC3)', patches: [{ file: 'custom_components/houseplan/frontend_registration.py', find: ' urls = {FRONTEND_URL}\n' + ' domain_data[FRONTEND_STATIC_REGISTERED_KEY] = urls', replace: ' urls = {FRONTEND_URL, "/houseplan_files/houseplan-panel.js"}\n' + ' domain_data[FRONTEND_STATIC_REGISTERED_KEY] = urls', }], }, { id: 'panel-cleanup-drops-generation-guard', guard: 'python3 -m pytest tests_backend/test_ha_panel_registration.py -q -p no:cacheprovider ' + '-k "generation_and_identity_guards"', because: 'a callback from an obsolete setup generation must not remove the current generation ' + 'even when it still retains an owned object reference (#486 AC2)', patches: [{ file: 'custom_components/houseplan/panel_registration.py', find: ' and domain_data.get(PANEL_GENERATION_KEY) == state.generation\n', replace: ' and True # mutant: setup generation is ignored\n', }], }, { id: 'panel-cleanup-drops-identity-guard', guard: 'python3 -m pytest tests_backend/test_ha_panel_registration.py -q -p no:cacheprovider ' + '-k "generation_and_identity_guards"', because: 'route ownership is the exact registry object, not the route name; otherwise an ' + 'external replacement is deleted during unload (#486 AC2)', patches: [{ file: 'custom_components/houseplan/panel_registration.py', find: ' if panels.get(PANEL_URL_PATH) is not state._owned_panel:', replace: ' if PANEL_URL_PATH not in panels: # mutant: foreign identity accepted', }], }, { id: 'panel-cleanup-uses-new-remove-keyword', guard: 'python3 -m pytest tests_backend/test_ha_panel_registration.py -q -p no:cacheprovider ' + '-k "generation_and_identity_guards"', because: 'the supported minimum exposes only the two-argument remove call; adding a newer ' + 'keyword makes unload leave the owned sidebar panel behind (#486 AC2/AC14)', patches: [{ file: 'custom_components/houseplan/panel_registration.py', find: ' # Keep the HA 2024.6-compatible two-argument call. In particular, do\n' + ' # not pass the newer warn_if_unknown keyword.\n' + ' frontend.async_remove_panel(hass, PANEL_URL_PATH)', replace: ' # mutant: use an API shape unavailable on minimum HA\n' + ' frontend.async_remove_panel(hass, PANEL_URL_PATH, warn_if_unknown=False)', }], }, { id: 'panel-accepts-unverifiable-ownership', guard: 'python3 -m pytest tests_backend/test_ha_panel_registration.py -q -p no:cacheprovider ' + '-k "unreadable_registry_removes_just_registered"', because: 'a successful public registration without a captured registry identity must be ' + 'removed immediately, never left for a later blind route cleanup (#486 AC2)', patches: [{ file: 'custom_components/houseplan/panel_registration.py', find: ' if owned_panel is None:', replace: ' if False: # mutant: unverifiable ownership is accepted', }], }, { id: 'panel-readonly-empty-bypasses-write-capability', guard: 'node demo/smoke_houseplan_panel.mjs', because: 'an empty read-only panel must stay passive and lazy; bypassing the server write ' + 'capability would expose Add space and load onboarding for a household viewer (#486 AC10)', patches: [{ file: 'src/houseplan-card.ts', find: ' if (this._serverCanWrite !== null) return this._serverCanWrite;', replace: ' if (this._serverCanWrite !== null) return true; // mutant: read-only becomes writer', }], }, { id: 'sections-grid-falls-back-to-viewport-height', guard: 'node demo/smoke_sections_resize.mjs', because: 'HA Sections owns a fixed row slot; treating grid like an ordinary dashboard card ' + 'restores the 100dvh stage and makes the card overflow its selected height (#648 AC2/AC7)', patches: [{ file: 'src/houseplan-card.ts', find: " private get _containerOwnedHeight(): boolean { return this.panelHost || this.layout === 'grid'; }", replace: ' private get _containerOwnedHeight(): boolean { return this.panelHost; } // mutant: viewport height', }], }, { id: 'sections-editor-transition-uses-window-height', guard: 'node demo/smoke_sections_resize.mjs', because: 'editor chrome must borrow space inside the selected Sections row slot; using ' + 'window.innerHeight makes the animated stage escape the card at minimum height (#648 AC5)', patches: [{ file: 'src/houseplan-card.ts', find: 'from.stageHeight + from.editorChromeHeight - targetChromeHeight', replace: 'innerHeight - targetChromeHeight /* mutant: ignore the fixed card budget */', }], }, { id: 'sections-grid-drops-stage-flex-chain', guard: 'node demo/smoke_sections_resize.mjs', because: 'host and ha-card height are insufficient when the stage is not the shrinking flex ' + 'child; header plus scene then leave a gap or overflow the Sections slot (#648 AC2/AC5)', patches: [{ file: 'src/styles/base.styles.ts', find: ' :host([layout="grid"]) .stage,\n' + ' :host([layout="grid"]) .empty {', replace: ' :host([layout="grid-disabled"]) .stage,\n' + ' :host([layout="grid-disabled"]) .empty { /* mutant: grid stage is not flex */', }], }, { id: 'sections-resize-drops-stage-refit-observer', guard: 'node demo/smoke_sections_resize.mjs', because: 'a row resize changes the stage aspect and hit geometry; without the existing stage ' + 'observer the camera retains a stale aspect even though CSS resized the box (#648 AC4/AC8)', patches: [{ file: 'src/houseplan-card.ts', find: ' this._roViewport.observe(stage);', replace: ' // mutant: Sections resizes never reach camera refit', }], }, { id: 'panel-ignores-pre-upgrade-properties', guard: 'node demo/smoke_houseplan_panel.mjs', because: 'HA assigns hass/narrow/route/panel before the top-level-await entry defines the ' + 'element; values left as own data properties shadow the accessors and the card never ' + 'receives hass, so /houseplan stays blank on first open (#488)', patches: [{ file: 'src/houseplan-panel.ts', find: ' if (!Object.prototype.hasOwnProperty.call(this, key)) continue;', replace: ' continue; // mutant: pre-upgrade properties are left shadowing the accessors', }], }, { id: 'panel-host-height-from-parent', guard: 'node demo/smoke_houseplan_panel.mjs', because: ' has no height, so a percentage host height resolves to auto and ' + 'the stage collapses to 0 px; the panel must size itself from the viewport (#488)', patches: [{ file: 'src/houseplan-panel.ts', find: ' height: calc(100dvh - var(--safe-area-inset-top, 0px) - var(--safe-area-inset-bottom, 0px));', replace: ' height: 100%; /* mutant: back to the parent-relative height */', }], }, { id: 'version-recovery-treats-unknown-as-mismatch', guard: 'node --test --test-name-pattern="malformed values stay unknown" ' + 'test/version-recovery.test.mjs', because: 'a missing or malformed integration_version must clear stale state and stay unknown, ' + 'not manufacture a reload request (#462 AC6)', patches: [{ file: 'src/version-recovery.ts', find: " if (!normalizedFrontend || !normalizedBackend) return { kind: 'unknown' };", replace: " if (!normalizedFrontend || !normalizedBackend) return { kind: 'mismatch', frontend: normalizedFrontend || 'unknown', backend: normalizedBackend || 'unknown' };", }], }, { id: 'version-recovery-delays-config-capability-adoption', guard: 'node demo/smoke_version_recovery.mjs', because: 'a successful config/get must set or clear its runtime capabilities before a sibling ' + 'layout request or later asset preparation can reject the structural candidate (#462 AC6)', patches: [{ file: 'src/version-recovery-card.ts', find: ' (response) => adoptCardConfigCapabilities(host, response),', replace: ' () => undefined, // mutant: aggregate consumers own capability adoption', }], }, { id: 'version-recovery-auto-reloads-ordinary-view', guard: 'node --test --test-name-pattern="ordinary mode always" ' + 'test/version-recovery.test.mjs', because: 'outside kiosk a version mismatch must never reload without the user pressing the ' + 'trusted action, even after an arbitrary wait (#462 AC7)', patches: [{ file: 'src/version-recovery.ts', find: ' if (!this._input.kiosk) {', // Условие ложно в рантайме и не статически: иначе внутри мёртвой ветки // теряется сужение `_relation` до варианта `mismatch` (#569). replace: " if (!this._input.kiosk && String(this._relation.kind) === 'mutant-never') {", }], }, { id: 'version-recovery-ignores-editor-state', guard: 'node --test --test-name-pattern="card adapter maps editor state" ' + 'test/version-recovery-card.test.mjs', because: 'a kiosk card in an editor must preserve unsaved work instead of silently reloading ' + 'when its versions differ (#462 AC8)', patches: [{ file: 'src/version-recovery-card.ts', find: " viewOnly: host._config?.kiosk === true && host._mode === 'view' && !host._editing,", replace: ' viewOnly: true,', }], }, { id: 'version-recovery-ignores-dialog-state', guard: 'node --test --test-name-pattern="card adapter maps blocking surfaces" ' + 'test/version-recovery-card.test.mjs', because: 'a first-class dialog or contextual surface must block the kiosk auto-reload ' + 'rather than disappear underneath it (#462 AC8)', patches: [{ file: 'src/version-recovery-card.ts', find: ' surfacesIdle,', replace: ' surfacesIdle: true,', }], }, { id: 'version-recovery-ignores-pending-config-write', guard: 'node --test --test-name-pattern="card adapter maps config writes" ' + 'test/version-recovery-card.test.mjs', because: 'a pending configuration write must block kiosk reload so an accepted edit cannot ' + 'be lost during version recovery (#462 AC8)', patches: [{ file: 'src/version-recovery-card.ts', find: ' configWritesIdle: host._writesPending === 0 && !host._saveConfigDebounced.pending(),', replace: ' configWritesIdle: true,', }], }, { id: 'version-recovery-ignores-interaction-pause', guard: 'node --test --test-name-pattern="card adapter maps interaction pause" ' + 'test/version-recovery-card.test.mjs', because: 'recent pointer, keyboard, touch and native more-info interaction must defer the ' + 'silent kiosk reload by the same shared pause contract (#462 AC8)', patches: [{ file: 'src/version-recovery-card.ts', find: ' interactionPauseElapsed: Date.now() >= host._cyclePausedUntil,', replace: ' interactionPauseElapsed: true,', }], }, { id: 'version-recovery-marks-target-after-reload', guard: 'node --test --test-name-pattern="marks the exact target before one reload" ' + 'test/version-recovery.test.mjs', because: 'the backend target must be durably claimed before navigation, otherwise the next ' + 'document can enter an infinite kiosk reload loop (#462 AC8)', patches: [{ file: 'src/version-recovery.ts', find: ' storage.setItem(VERSION_RELOAD_ATTEMPT_KEY, target);\n return \'claimed\';', replace: " return 'claimed'; // mutant: navigation happens before any durable claim", }], }, { id: 'version-recovery-ignores-stored-target', guard: 'node --test --test-name-pattern="same target is once per tab" ' + 'test/version-recovery.test.mjs', because: 'all full cards in one tab must honour the attempted backend target so a stale ' + 'frontend cannot reload repeatedly (#462 AC9)', patches: [{ file: 'src/version-recovery.ts', find: " return storage.getItem(VERSION_RELOAD_ATTEMPT_KEY) === target ? 'attempted' : 'fresh';", replace: " return 'fresh'; // mutant: forget the tab-wide target guard", }], }, { id: 'space-copy-title-always-reuses-two', guard: 'node --test --test-name-pattern="first free numbered" test/space-copy.test.mjs', because: 'copy naming must skip occupied suffixes instead of silently proposing a duplicate (#456 AC2)', patches: [{ file: 'src/space-copy.ts', find: ' while (names.has(`${base} (${suffix})`)) suffix++;', replace: ' while (false && names.has(`${base} (${suffix})`)) suffix++;', }], }, { id: 'space-copy-always-asks-to-optimize', guard: 'node --test --test-name-pattern="no extra confirmation" test/space-copy-runtime.test.mjs', because: 'an already clean plan must use the name submission as its only confirmation (#456 AC3)', patches: [{ file: 'src/space-copy-runtime.ts', find: ' if (optimized.changed) {', replace: ' if (true) {', }], }, { id: 'space-copy-skips-required-optimize', guard: 'node --test --test-name-pattern="durable before the copy" test/space-copy-runtime.test.mjs', because: 'the accepted whole-plan Optimize must become durable before the copy is derived or written (#456 AC4)', patches: [{ file: 'src/space-copy-runtime.ts', find: ' await commitPlanOptimization(host, optimized.config, optimized.layout);', replace: ' await Promise.resolve();', }], }, { id: 'space-copy-ignores-unsafe-optimize-preflight', guard: 'node --test --test-name-pattern="unsafe Optimize" test/space-copy-runtime.test.mjs', because: 'a failed whole-plan geometry proof must stop before confirmation or either write (#456 AC5)', patches: [{ file: 'src/space-copy-runtime.ts', find: ' if (!preflight.ok) {', replace: ' if (false && !preflight.ok) {', }], }, { id: 'space-copy-drops-existing-partitions', guard: 'node --test --test-name-pattern="complete allowed physical surface" test/space-copy.test.mjs', because: 'copying room contours alone loses independent walls already drawn in the source (#456 AC6)', patches: [{ file: 'src/space-copy.ts', find: ' const sourcePartitions = geometryList(source.partitions);', // Пустой литерал выводится как `never[]`, и дальше падают `.id`/`.a`. // Тип сохраняется, содержимое теряется — именно это мутант и заявляет (#569). replace: ' const sourcePartitions = geometryList(source.partitions).slice(0, 0);', }], }, { id: 'space-copy-keeps-opening-device-binding', guard: 'node --test --test-name-pattern="complete allowed physical surface" test/space-copy.test.mjs', because: 'copied openings are geometry only and must not control the source contact or lock (#456 AC7)', patches: [{ file: 'src/space-copy.ts', find: ' delete copied.contact;', replace: '', }], }, { id: 'space-copy-shares-settings-object', guard: 'node --test --test-name-pattern="complete allowed physical surface" test/space-copy.test.mjs', because: 'editing one copy must not mutate the source display or backdrop state through an alias (#456 AC8)', patches: [{ file: 'src/space-copy.ts', find: " if (key !== 'view_box' && own(source, key)) copiedSpace[key] = clone(source[key]);", replace: " if (key !== 'view_box' && own(source, key)) copiedSpace[key] = source[key];", }], }, { id: 'space-copy-leaks-room-drafts', guard: 'node --test --test-name-pattern="complete allowed physical surface" test/space-copy.test.mjs', because: 'the new floor must start without room identity or unfinished room chains (#456 AC9)', patches: [{ file: 'src/space-copy.ts', find: ' if (copiedPartitions.length) copiedSpace.partitions = copiedPartitions;', replace: " if (own(source, 'room_drafts')) copiedSpace.room_drafts = clone(source.room_drafts);\n" + ' if (copiedPartitions.length) copiedSpace.partitions = copiedPartitions;', }], }, { id: 'space-copy-ignores-wall-limit', guard: 'node --test --test-name-pattern="collection boundaries" test/space-copy.test.mjs', because: 'an oversized copy must fail before it can create an optimistic or server-side partial state (#456 AC10)', patches: [{ file: 'src/space-copy.ts', find: " if (partitionCount > SPACE_COPY_LIMITS.partitions) throw new SpaceCopyError('partitions_limit');", replace: " if (false) throw new SpaceCopyError('partitions_limit');", }], }, { id: 'space-copy-rejection-keeps-local-copy', guard: 'node --test --test-name-pattern="keeps accepted Optimize" test/space-copy-runtime.test.mjs', because: 'a rejected second write must restore server truth without rolling back the accepted Optimize (#456 AC11)', patches: [{ file: 'src/space-copy-runtime.ts', find: ' if (host._rollbackOptimistic(attempt)) invalidateConfig(host);', replace: '', }, { file: 'src/space-copy-runtime.ts', find: ' await host._reloadConfigOnly(true);', replace: ' await Promise.resolve();', }], }, { id: 'space-copy-does-not-select-result', guard: 'node --test --test-name-pattern="one config write" test/space-copy-runtime.test.mjs', because: 'after an accepted write the user must enter the clean copied space, not remain on the source (#456 AC12)', patches: [{ file: 'src/space-copy-runtime.ts', find: ' host._commitSpace(result.space.id, true);', replace: '', }], }, { id: 'render-invalidation-renders-irrelevant-ha', guard: 'node demo/smoke_render_invalidation.mjs', because: 'an irrelevant HA state row must still enter lifecycle intake without scheduling ' + 'a full Lit render; the production smoke owns this card/runtime wiring (#451)', patches: [{ file: 'src/houseplan-card.ts', find: ' if (!render && !this._summary?.observeHassComposition()) return;', replace: ' if (false && !render && !this._summary?.observeHassComposition()) return;', }], }, { id: 'smoke-guard-blind-to-tail', guard: 'node demo/guard/verify-guard.mjs', because: 'the uncaught-exception guard must read its counter AFTER the page delivered ' + 'its events; reading it first is the defect of #404 and looks identical to a ' + 'working guard in everything but the outcome', patches: [{ file: 'demo/serve.mjs', find: ' await roundTripLivePages();\n if (_pageErrors) _failures.push(', replace: ' if (_pageErrors) _failures.push(', }], }, { id: 'smoke-guard-forgets-to-register-pages', guard: 'node demo/guard/verify-guard.mjs', because: 'the round-trip and the page registry are two halves of one fix (#404): a ' + 'mutant on either half alone leaves the other unproven', patches: [{ file: 'demo/serve.mjs', find: ' _livePages.add(page);', replace: '', }], }, { id: 'support-preview-replacement-keeps-old-token', guard: 'node scripts/backend-test-guard.mjs ' + 'support_preview_replacement_and_discard_are_draft_local ' + 'tests_backend/test_ha_websocket.py', because: 'refreshing one support draft must invalidate its previous token while leaving ' + 'another draft usable; discard success alone cannot prove either fact (#421 AC1)', patches: [{ file: 'custom_components/houseplan/websocket_api.py', find: ' for old_token, record in list(rt.support_previews.items()):\n' + ' if record.get("owner") == owner and record.get("draft_id") == msg["draft_id"]:\n' + ' rt.support_previews.pop(old_token, None)\n', replace: '', }], }, { id: 'support-preview-discard-keeps-token', guard: 'node scripts/backend-test-guard.mjs ' + 'support_preview_replacement_and_discard_are_draft_local ' + 'tests_backend/test_ha_websocket.py', because: 'an idempotent discard response is not proof of invalidation; a later submit must ' + 'fail because the owned token was actually removed (#421 AC2)', patches: [{ file: 'custom_components/houseplan/websocket_api.py', find: ' rt.support_previews.pop(msg["token"], None)\n' + ' connection.send_result(msg["id"], {"ok": True})', replace: ' connection.send_result(msg["id"], {"ok": True})', }], }, { id: 'support-preview-submit-skips-ttl-prune', guard: 'node scripts/backend-test-guard.mjs ' + 'support_preview_token_expires_at_ttl_without_transport ' + 'tests_backend/test_ha_websocket.py', because: 'the exact TTL boundary must reject an expired attachment before relay transport; ' + 'without submit-time pruning the stale bytes remain usable (#421 AC3)', patches: [{ file: 'custom_components/houseplan/websocket_api.py', find: ' _prune_support_previews(rt)\n' + ' token = msg.get("preview_token")', replace: ' token = msg.get("preview_token")', }], }, { id: 'report-page-errors-skips-round-trip', guard: 'node demo/guard/verify-guard.mjs', because: 'reportPageErrors() is a verdict path separate from finish(); removing only its ' + 'delivery round-trip must leave the dedicated tail-error probe visibly red (#421 AC4/AC5)', patches: [{ file: 'demo/serve.mjs', find: 'export async function reportPageErrors() {\n' + ' await roundTripLivePages();\n' + ' if (!_pageErrors) return false;', replace: 'export async function reportPageErrors() {\n' + ' if (!_pageErrors) return false;', }], }, { id: 'docs-fingerprint-refresh-erases-acceptance-trace', guard: 'node --test --test-name-pattern="fingerprint-only refresh" ' + 'test/docs-accept.test.mjs', because: 'a source-fingerprint refresh changes no pixels and must preserve the earlier ' + 'human acceptance trace instead of manufacturing an empty one (#421 AC6/AC7)', patches: [{ file: 'scripts/docs-accept.mjs', find: ' : { ...(previousAcceptance || {}), lastWriteWasFingerprintOnly: true },', replace: ' : { declared: [], witnesses: 0, floor: 0 },', }], }, { id: 'i18n-dead-key-returns', guard: 'node --test test/i18n-dead-keys.test.mjs', because: 'a translation key without any literal, dynamic-family or derived consumer must ' + 'not silently return to all shipped dictionaries (#406)', patches: [{ file: 'src/i18n/en.json', find: ' "confirm.unlock_title": "Unlock?",', replace: ' "confirm.unlock": "Unlock {name}?",\n' + ' "confirm.unlock_title": "Unlock?",', }], }, { id: 'dialog-native-surface-stretches-to-viewport', guard: 'node demo/smoke_dialog_modal_recovery.mjs', because: 'the native top-layer shell must shrink-wrap its surface; restoring width:auto ' + 'reproduces the left-pinned confirmation from #463 even though :modal remains true', patches: [{ file: 'src/hp-dialog.ts', find: ' width: fit-content;\n height: fit-content;', replace: ' width: auto;\n height: fit-content;', }], }, { id: 'dialog-native-update-recovery-disabled', guard: 'node demo/smoke_dialog_modal_recovery.mjs', because: 'an open-but-nonmodal native shell must be reconciled on a later Lit update without ' + 'a duplicate or hp-close event; firstUpdated alone cannot recover that #463 state', patches: [{ file: 'src/hp-dialog.ts', find: ' protected updated(changed: PropertyValues): void {\n' + ' super.updated(changed);\n' + ' this._ensureNativeModal();\n' + ' }', replace: ' protected updated(changed: PropertyValues): void {\n' + ' super.updated(changed);\n' + ' // mutant: update reconciliation removed\n' + ' }', }], }, { id: 'dialog-native-reconnect-recovery-disabled', guard: 'node demo/smoke_dialog_modal_recovery.mjs', because: 'detaching a modal drops it from the browser top layer while its rendered shell ' + 'survives; reconnect must schedule one bounded reconciliation for the same node (#463)', patches: [{ file: 'src/hp-dialog.ts', find: ' queueMicrotask(() => {\n' + ' // Re-entering the top layer lets the UA choose a default focus target.\n' + ' // Restore our deterministic initial target only on a real reconnect\n' + ' // recovery; ordinary updates must never steal focus from a live field.\n' + ' if (this._ensureNativeModal()) this._focusInitial();\n' + ' });', replace: ' // mutant: reconnect reconciliation removed', }], }, { id: 'dialog-ha-rejected-close-reopen-disabled', guard: 'node demo/smoke_dialog_modal_recovery.mjs', because: 'an HA shell may close outside Lit; rejected close must force live() to reconcile the ' + 'same physical modal instead of leaving its preserved draft invisible (#607)', patches: [{ file: 'src/hp-dialog.ts', find: ' return html` 1) {\n return { kind: \'ambiguous\', routeIds: matched.map((route) => route.id).sort() };\n }', replace: ' if (matched.length > 1) {\n return { kind: \'ready\', route: matched[0] };\n }', }], }, { id: 'vacuum-route-missing-space-falls-back-to-dock', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/vacuum-routes.test.mjs', because: 'a route pointing at a deleted space must fail visibly, not quietly render the ' + 'robot in the dock space it no longer belongs to (#162, M-B)', patches: [{ file: 'src/vacuum-routes.ts', find: " if (input.spaceIds && !input.spaceIds.has(route.space)) return { kind: 'missing_space', route };", replace: ' // mutant: missing space ignored', }], }, { id: 'vacuum-route-unmapped-draws-anyway', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/vacuum-routes.test.mjs', because: 'an unmapped active map must draw nothing: reusing the last matrix puts the ' + 'robot on a floor nobody mapped it to (#162, M-C)', patches: [{ file: 'src/vacuum-routes.ts', find: ' if (observed === route.map_id) matched.push(route);', replace: ' matched.push(route);', }], }, { id: 'vacuum-route-identity-duplicates-allowed', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/vacuum-routes.test.mjs', because: 'two routes with the same (source, map_id) make the resolver permanently ' + 'ambiguous and silently share one calibration (#162, M-D)', patches: [{ file: 'src/vacuum-routes.ts', find: " if (seenIdentity.has(identity)) problems.push(issue(markerId, id, 'duplicate_identity'));", replace: ' // mutant: duplicate identity accepted', }], }, { id: 'vacuum-legacy-run-adopts-the-first-candidate', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/vacuum-routes.test.mjs', because: 'a legacy run carries no source, so two candidates must be ambiguous_run and ' + 'draw nowhere; picking the first one invents a floor for old data (#162, M-H)', patches: [{ file: 'src/vacuum-routes.ts', find: " if (candidates.length === 1) return { kind: 'adopted', route: candidates[0] };", replace: " if (candidates.length >= 1) return { kind: 'adopted', route: candidates[0] };", }], }, { id: 'vacuum-overlay-ignores-the-rendered-space', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/vacuum-routes.test.mjs', because: 'the live puck belongs to the ACTIVE ROUTE space, not to every space that ' + 'happens to be on screen: dropping the check draws one robot on both floors (#162)', patches: [{ file: 'src/vacuum-routes.ts', find: " const live = active && active.space === input.renderSpace\n ? normalizeRouteMatrix(active.calibration) : null;", replace: ' const live = active ? normalizeRouteMatrix(active.calibration) : null;', }], }, { id: 'vacuum-previous-run-follows-the-robot', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/vacuum-routes.test.mjs', because: 'the previous run belongs to the space of its own route, so it keeps showing ' + 'where the robot has been after it moved to another map (#162, AC10)', patches: [{ file: 'src/vacuum-routes.ts', find: ' const previousAllowed = !!previousRoute\n && previousRoute.space === input.renderSpace', replace: ' const previousAllowed = !!previousRoute\n && !!active && active.space === input.renderSpace', }], }, { id: 'vacuum-run-forgets-its-route', guard: 'node scripts/backend-test-guard.mjs ' + 'route_change_starts_a_new_run_even_on_the_same_map_id ' + 'tests_backend/test_trails.py', because: 'a stored run must remember which route wrote it, or two maps that share a map ' + 'id across different cameras collapse into one run on the wrong floor (#162, M-F)', patches: [{ file: 'custom_components/houseplan/trails.py', find: ' if route_id:\n cur["route_id"] = route_id', replace: ' if False:\n cur["route_id"] = route_id', }], }, { id: 'vacuum-retargeted-route-keeps-its-old-trails', guard: 'node scripts/backend-test-guard.mjs ' + 'drop_unknown_routes_touches_only_runs_that_name_a_route ' + 'tests_backend/test_trails.py', because: 'a route that was deleted or re-targeted to another space must take its runs ' + 'with it; keeping them replays an old cleanup on a floor it never happened on ' + '(#162, M-E server half)', patches: [{ file: 'custom_components/houseplan/trails.py', find: ' if isinstance(stored, str) and stored and stored not in route_ids:', replace: ' if False:', }], }, { id: 'vacuum-route-warning-stays-silent', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/vacuum-routes.test.mjs', because: 'a moving robot that is drawn nowhere must say so on the dock: silence reads as ' + '"not cleaning", and the user cannot discover an unassigned map (#162)', patches: [{ file: 'src/vacuum-routes.ts', find: " case 'unmapped': case 'needs_calibration': case 'ambiguous': case 'missing_space':\n return resolution.kind;", // Метки остаются валидными, молчит сам ответ: бракованная метка ломала // `switch` по union и сужение `resolution` (#569). replace: " case 'unmapped': case 'needs_calibration': case 'ambiguous': case 'missing_space':\n return null;", }], }, { id: 'vacuum-route-validation-accepts-a-dead-space', guard: 'node scripts/backend-test-guard.mjs ' + 'invalid_routes_are_rejected ' + 'tests_backend/test_vacuum_route_validation.py', because: 'a route saved against a space that does not exist can never resolve, so the ' + 'write has to be refused at the boundary rather than discovered as a missing robot ' + '(#162, M-D backend half)', patches: [{ file: 'custom_components/houseplan/vacuum_routes.py', find: ' elif space_ids is not None and space not in space_ids:\n problems.append(issue(route_id, "unknown_space"))', replace: ' elif False:\n problems.append(issue(route_id, "unknown_space"))', }], }, { id: 'space-delete-keeps-foreign-vacuum-routes', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="уносит только свои маршруты" ' + 'test/space-deletion.test.mjs', because: 'a deleted space must take the robot map assignments that pointed at it; leaving ' + 'them behind keeps a route nothing can resolve and a robot that never appears (#162, AC16)', patches: [{ file: 'src/space-deletion.ts', find: ' const kept = routes.filter((route) => route?.space !== spaceId);', replace: ' const kept = routes;', }], }, { id: 'vacuum-empty-routes-revive-legacy-frontend', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="explicit empty routes remain authoritative" ' + 'test/vacuum-routes.test.mjs', because: 'an explicit empty route array means the user removed every map assignment; ' + 'treating it as false revives stale legacy calibration in the frontend (#443, AC1)', patches: [{ file: 'src/vacuum-routes.ts', find: ' if (Array.isArray(explicit)) {', replace: ' if (Array.isArray(explicit) && explicit.length) {', }], }, { id: 'vacuum-empty-routes-revive-legacy-backend', guard: 'node scripts/backend-test-guard.mjs ' + 'explicit_empty_routes_remain_authoritative tests_backend/test_vacuum_routes.py', because: 'backend trail reconciliation must share the frontend empty-array authority; ' + 'the old truthiness check otherwise assigns a deleted map again (#443, AC2)', patches: [{ file: 'custom_components/houseplan/vacuum_routes.py', find: ' if isinstance(explicit, list):', replace: ' if isinstance(explicit, list) and explicit:', }], }, { id: 'vacuum-space-export-drops-empty-authority', guard: 'node scripts/backend-test-guard.mjs ' + 'issue_443_space_export_preserves_explicit_empty_routes ' + 'tests_backend/test_ha_import_export.py', because: 'single-space export must preserve an explicit empty list after filtering; ' + 'writing null would make retained legacy calibration authoritative on import (#443, AC3)', patches: [{ file: 'custom_components/houseplan/import_export.py', find: ' vacuum["map_routes"] = kept_routes', replace: ' vacuum["map_routes"] = kept_routes or None', }], }, { id: 'vacuum-overlay-back-to-the-dock-space-filter', guard: 'node demo/smoke_vacuum_multifloor.mjs', because: 'the overlay layer must see every robot of the plan, not only those whose DOCK ' + 'is in the space on screen: the old filter is exactly why a multi-floor robot could ' + 'never appear on its second floor (#162, AC2)', patches: [{ file: 'src/houseplan-card.ts', find: ' ${this._renderVacuums(this._renderVacuumDevices, view, space.id)}', replace: ' ${this._renderVacuums(devs, view, space.id)}', }], }, { id: 'vacuum-calibration-solves-against-the-dock', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/vacuum-routes.test.mjs', because: 'a matrix solved against the DOCK space and applied on another floor is wrong ' + 'wherever the two plans differ — calibration belongs to the route (#162, AC8)', patches: [{ file: 'src/vacuum-route-edit.ts', find: ' return { space: route?.space || dockSpace, routeId: route?.id || \'\' };', replace: ' return { space: dockSpace, routeId: route?.id || \'\' };', }], }, { id: 'vacuum-manual-fit-after-proposal-uses-the-dock', guard: 'node demo/smoke_vacuum_multifloor.mjs', because: 'the high-residual proposal is refined against the geometry the matrix was solved ' + 'against — opening it on the dock floor fits the robot to the wrong plan (#162, AC8, ' + 'the stateful half the pure calibrationTarget mutant cannot reach)', patches: [{ file: 'src/vacuum-calibration-write.ts', find: ' const space = proposal.space || device.space;', replace: ' const space = device.space;', }], }, { id: 'area-snapshot-cleanup-ignores-authority', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="limited frames and runtime reset" ' + 'test/device-area-relocation.test.mjs', because: 'temporary registry absence must not erase device Area provenance before the ' + 'registry is authoritative (#406/#419)', patches: [{ file: 'src/device-area-relocation.ts', find: ' if (!options.authoritative || !namespaceNonEmpty) continue;', replace: ' if (!namespaceNonEmpty) continue;', }], }, { id: 'area-snapshot-cleanup-forgets-registry-evidence', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="orphan cleanup uses full registry evidence" ' + 'test/device-area-relocation.test.mjs', because: 'a filtered presentation roster is not proof that a live HA binding disappeared ' + 'and must not erase its Area provenance (#419 AC1/AC12)', patches: [{ file: 'src/device-area-relocation.ts', find: ' const exists = markerBindings.has(binding)\n' + ' || ids.some((id) => markerIds.has(id))\n' + " || (kind === 'device' ? deviceIds.has(ref) : entityIds.has(ref) || liveEntityIds.has(ref));", replace: ' const exists = false;', }], }, { id: 'area-snapshot-cleanup-trusts-first-absence', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="requires two distinct" ' + 'test/device-area-relocation.test.mjs', because: 'one shortened authoritative frame is only a reason to re-check, never enough ' + 'evidence for destructive cleanup (#419 AC5/AC12)', patches: [{ file: 'src/device-area-relocation.ts', find: ' if (firstRevision === undefined) {\n' + ' candidates.set(binding, options.revision);\n' + ' needsConfirmationRefresh = true;\n' + ' continue;\n' + ' }', replace: ' if (firstRevision === undefined) {\n' + ' for (const id of ids) removeIds.add(id);\n' + ' continue;\n' + ' }', }], }, { id: 'area-snapshot-cleanup-trusts-empty-namespace', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="empty registry namespaces" ' + 'test/device-area-relocation.test.mjs', because: 'even repeated empty registry namespaces are an unsafe basis for deleting every ' + 'saved Area provenance entry (#419 AC2/AC12)', patches: [{ file: 'src/device-area-relocation.ts', find: ' if (!options.authoritative || !namespaceNonEmpty) continue;', replace: ' if (!options.authoritative) continue;', }], }, { id: 'settings-help-party1-placement-removed', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 86 Party 1" test/i18n.test.mjs', because: 'the critical-settings help inventory must fail when one agreed trigger disappears (#86)', patches: [{ file: 'src/houseplan-editor-runtime.ts', find: "this._help('device_inbox.show_hidden.help')", replace: 'nothing', }], }, { id: 'room-climate-ignores-marker-placement', guard: 'node demo/smoke_room_climate_placement.mjs', because: 'a real sensor manually placed into a House Plan room must leave its registry HA ' + 'Area and feed the local room label, tooltip and temperature fill exactly once (#317)', patches: [{ file: 'src/devices.ts', find: ' const target = entityTargets.get(eid)\n' + ' || (reg.device_id ? deviceTargets.get(reg.device_id) : null)\n' + ' || reg.area_id || dev?.area_id || null;', replace: ' const target = reg.area_id || dev?.area_id || null;', }], }, { id: 'editor-runtime-fingerprint-handshake', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/editor-runtime-loader.test.mjs', because: 'a mixed entry/editor build must fail closed instead of installing incompatible code (#337)', patches: [{ file: 'src/editor-runtime-loader.ts', find: ' if (module.fingerprint !== this.options.expectedFingerprint) {', replace: ' if (false && module.fingerprint !== this.options.expectedFingerprint) {', }], }, { id: 'editor-runtime-one-retry', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/editor-runtime-loader.test.mjs', because: 'a transient chunk failure gets exactly one bounded retry (#337)', patches: [{ file: 'src/editor-runtime-loader.ts', find: ' for (const attempt of [0, 1] as const) {', replace: ' for (const attempt of [0] as const) {', }], }, { id: 'wallthick-hover-floor-back', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="uses the exact physical wall width" ' + 'test/grid-scale.test.mjs', because: 'restoring the raw grid-unit floor inflates a 50 cm hover strip to 75 cm ' + 'on a 30 cm cell instead of matching the physical wall body from #303', patches: [{ file: 'src/grid-scale.ts', find: ' ? wallCmToUnits(thicknessCm, normalizedCellCm, normalizedPitch) / 2\n', replace: ' ? Math.max(\n' + ' wallCmToUnits(thicknessCm, normalizedCellCm, normalizedPitch) / 2,\n' + ' normalizedPitch * 1.25,\n' + ' )\n', }], }, { id: 'wallthick-zero-strip-not-visual', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="zero-thickness hover" ' + 'test/grid-scale.test.mjs', because: 'the visible strip for a zero-thickness wall must have one physical size across ' + 'cell scales rather than growing with the coordinate grid', patches: [{ file: 'src/grid-scale.ts', find: ' : gridVisualUnits(normalizedPitch * 1.5, normalizedCellCm);', replace: ' : normalizedPitch * 1.5;', }], }, { id: 'wallthick-hit-narrowed', guard: 'node demo/smoke_wallthick_hover_width.mjs', because: 'the visual correction must not make the Thickness tool harder to hit with a ' + 'pointer or finger; its six-pitch hit radius is a separate UX contract', patches: [{ file: 'src/houseplan-card.ts', find: ' const space = this._spaceModel();\n' + ' if (!space) return null;\n' + ' const pull = this._gridPitch * 6;\n' + ' const cuts = this._openCuts();', replace: ' const space = this._spaceModel();\n' + ' if (!space) return null;\n' + ' const pull = this._gridPitch * 2;\n' + ' const cuts = this._openCuts();', }], }, { id: 'opening-search-filter-dead', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="matches friendly name" ' + 'test/opening-entity-search.test.mjs', because: 'the opening picker must actually apply the typed query; returning the unfiltered ' + 'candidate list recreates the original hundred-row scrolling problem from #301', patches: [{ file: 'src/logic.ts', find: ' const normalized = query.trim().toLowerCase();', replace: " const normalized = '';", }], }, { id: 'opening-search-name-only', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="matches entity id" ' + 'test/opening-entity-search.test.mjs', because: 'large HA installations often identify the wanted opening by entity_id rather ' + 'than a duplicated friendly name, so a label-only search violates #301 directly', patches: [{ file: 'src/logic.ts', find: ' candidate.label.toLowerCase().includes(normalized)\n' + ' || candidate.value.toLowerCase().includes(normalized))', replace: ' candidate.label.toLowerCase().includes(normalized))', }], }, { id: 'opening-search-order-resorted', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="preserves resolver order" ' + 'test/opening-entity-search.test.mjs', because: 're-sorting a filtered contact list hides door/window device classes behind ' + 'generic binary sensors and discards the deliberate resolver priority from #301', patches: [{ file: 'src/logic.ts', find: ' return filtered.slice(0, Math.max(0, limit));', replace: ' return [...filtered].sort((a, b) => a.label.localeCompare(b.label))' + '.slice(0, Math.max(0, limit));', }], }, { id: 'opening-search-hides-none', guard: 'node demo/smoke_opening_entity_search.mjs', because: 'the clear-binding action must remain first and visible for every query, including ' + 'a query with no matches; hiding it can trap a stale contact or lock on the opening', patches: [{ file: 'src/houseplan-card.ts', find: ' \n', replace: '', }], }, { id: 'opening-search-select-not-wired', guard: 'node demo/smoke_opening_entity_search.mjs', because: 'a visually filtered result is useless unless its click writes the exact entity_id ' + 'into opening.contact or opening.lock and closes the picker as required by #301', patches: [{ file: 'src/houseplan-card.ts', find: ' @click=${() => this._selectOpeningEntity(kind, candidate.value)}>', replace: ' @click=${() => this._selectOpeningEntity(kind, \'\')}>', }], }, { id: 'device-tombstone-blocks-child-picker', guard: 'node demo/smoke_binding_picker.mjs', because: 'a device tombstone must expose an active child in the catalog/Add flow when Show entities is on; ' + 'restoring only the exact device reproduces the user-visible dead end from #262', patches: [{ file: 'src/device-inbox.ts', find: ' if (isRemovedPlanEntity(h, eid, removed) && !removedBindings.has(value) && !childOfRemovedDevice) continue;', replace: ' if (isRemovedPlanEntity(h, eid, removed) && !removedBindings.has(value)) continue;', }], }, // #618: batch Hide/Show in the Devices catalog. { id: 'device-inbox-batch-eligibility-active', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 618" test/device-inbox.test.mjs', because: 'an HA-disabled, orphaned or unverified binding must be neither selectable nor counted in ' + '"Select all (N)"; otherwise a batch writes hidden flags the single-row action refuses (#618 B2)', patches: [{ file: 'src/device-inbox.ts', find: " return isInboxBatchTab(category) && status.kind === 'active';", replace: ' return isInboxBatchTab(category) && !!status.kind;', }], }, { id: 'device-inbox-show-keeps-stub', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 618" test/device-inbox.test.mjs', because: 'Show must keep the marker with hidden:false; dropping an automatic stub lets the seeder ' + 'hide the device again on the next rebuild (docs/FILTERING.md, #618 B5)', patches: [{ file: 'src/device-inbox.ts', find: ' && (marker.binding !== row.binding || marker.removed === true)),\n updated,\n ];', replace: ' && (marker.binding !== row.binding || marker.removed === true)),\n' + ' ...(hidden ? [updated] : []),\n ];', }], }, { id: 'device-inbox-batch-single-write', guard: 'node demo/smoke_device_inbox_batch.mjs', because: 'a batch of K rows must be exactly one houseplan/config/set with expected_rev; K writes ' + 'mean K revisions and K inert waits, the problem #618 exists to remove', patches: [{ file: 'src/device-inbox-batch.ts', find: ' await deps.saveConfigNow(); // #618 B5: one write per batch', replace: ' for (let write = 0; write < rows.length; write += 1) await deps.saveConfigNow(); ' + '// #618 B5: one write per batch', }], }, { id: 'device-inbox-batch-rollback', guard: 'node demo/smoke_device_inbox_batch.mjs', because: 'a rejected batch write must leave every marker unchanged; without the rollback the ' + 'catalog shows K rows moved that the server never accepted (#618 B8)', patches: [{ file: 'src/device-inbox-batch.ts', find: ' if (host._serverCfg === cfg) cfg.markers = previousMarkers; // #618 B8 rollback', replace: ' // rollback removed by mutant', }], }, { id: 'live-child-still-suppressed-by-parent-tombstone', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 262" test/devices.test.mjs', because: 'fixing only the picker lets the person select X but buildDevices and every ' + 'availability consumer still discard it after Save; a live exact child must win (#262)', patches: [{ file: 'src/devices.ts', find: ' if (removed.liveEntities.has(eid)) return false;\n', replace: '', }], }, { id: 'parent-tombstone-restores-all-siblings', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 262" test/devices.test.mjs', because: 'one restored child is an exact override, not permission for every sibling and ' + 'room aggregate of the deleted parent device to return (#262)', patches: [{ file: 'src/devices.ts', find: ' if (removed.liveEntities.has(eid)) return false;\n', replace: ' if (removed.liveEntities.size) return false;\n', }], }, { id: 'child-readd-clears-parent-tombstone', guard: 'node demo/smoke_binding_picker.mjs', because: 'saving one child must preserve the device tombstone; dropping all tombstones ' + 'resurrects the automatic parent and the siblings the person deliberately removed (#262)', patches: [{ file: 'src/houseplan-editor-runtime.ts', find: ` candidate.markers = candidate.markers.filter( (m) => m.id !== id && m.id !== oldId && (marker.binding === 'virtual' || m.binding !== marker.binding), );`, replace: ` candidate.markers = candidate.markers.filter( (m) => m.id !== id && m.id !== oldId && (marker.binding === 'virtual' || m.binding !== marker.binding) && (!marker.binding.startsWith('entity:') || m.removed !== true), );`, }], }, { id: 'orphan-space-detach-disabled', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="detaches a live marker" ' + 'test/space-reference-repair.test.mjs', because: 'an active marker with no exact copy or usable Area must lose only its dead space ' + 'field; retaining that one field is the original invisibility bug and must be caught ' + 'without relying on rendering (#244)', patches: [{ file: 'src/space-reference-repair.ts', find: ' } else {\n delete marker.space;\n targetSpace = null;\n' + ' report.markersDetached++;\n }', replace: ' } else {\n targetSpace = null;\n' + ' report.markersDetached++;\n }', }], }, { id: 'orphan-space-ambiguous-signature-guessed', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="ambiguous, truncated and malformed" ' + 'test/space-reference-repair.test.mjs', because: 'choosing the first of two independent import copies moves a device between plans ' + 'by array order; exact signature repair is safe only when the candidate is unique (#244)', patches: [{ file: 'src/space-reference-repair.ts', find: ' const candidates = spaceSignatures.get(root) || [];\n' + ' return candidates.length === 1 ? candidates[0] : null;', replace: ' const candidates = spaceSignatures.get(root) || [];\n' + ' return candidates[0] || null;', }], }, { id: 'orphan-cleanup-partial-registry-deletes', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="fails closed for limited registry" ' + 'test/space-reference-repair.test.mjs', because: 'limited registry access is not proof that an HA device or group was deleted; ' + 'treating it as authoritative destroys positions on permission/network failures (#252)', patches: [{ file: 'src/space-reference-repair.ts', find: ' const rosterAuthoritative = roster?.authoritative === true;', replace: ' const rosterAuthoritative = true;', }], }, { id: 'orphan-cleanup-proven-owners-kept', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="removes only proven room, device and group" ' + 'test/space-reference-repair.test.mjs', because: 'proof without deletion leaves the exact maintenance debt #252 exists to remove; ' + 'all three supported owner categories must change the candidate once and only once', patches: [{ file: 'src/space-reference-repair.ts', find: " if (status === 'absent') {\n delete layout[key];\n countRemoval(owner.kind);", replace: " if (status === 'absent') {\n countRemoval(owner.kind);", }], }, { id: 'chain-thickness-falls-back-to-default', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="chainSegmentCms fills a gap" ' + 'test/wall-face-graph.test.mjs', because: 'a segment whose thickness was not recorded must inherit the previous segment of ' + 'its own chain — the value the person watched on screen — instead of the global default ' + 'that silently replaced 30 cm with 15 cm in the stored plan (#234)', patches: [{ file: 'src/wall-face-graph.ts', find: ' const inherited = previous ?? active ?? fallback;', replace: ' const inherited = active ?? fallback;', }], }, { id: 'chain-thickness-live-tail-inherits-previous', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="live rubber-band" ' + 'test/wall-face-graph.test.mjs', because: 'the last missing thickness belongs to the live rubber-band, so a field change ' + 'between clicks must preview the value that the click will commit instead of inheriting ' + 'the preceding segment (#234)', patches: [{ file: 'src/wall-face-graph.ts', find: ' const liveTail = active ?? previous ?? fallback;', replace: ' const liveTail = previous ?? active ?? fallback;', }], }, { id: 'chain-thickness-preview-diverges', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="owns no fallback of its own" ' + 'test/wall-face-graph.test.mjs', because: 'the preview and every writer must read one resolver; two formulas for one meaning ' + 'is what let the editor show 30 cm while the config kept 15 (#234)', patches: [{ file: 'src/wall-face-graph.ts', find: ' result.push({ a: [a[0], a[1]], b: [b[0], b[1]], cm: cms[i] });', replace: ' result.push({ a: [a[0], a[1]], b: [b[0], b[1]], ' + 'cm: Number.isFinite(cms[i]) && cms[i] >= 0 ? cms[i] : 15 });', }], }, { id: 'chain-thickness-length-invariant-dropped', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="always returns exactly segmentCount" ' + 'test/wall-face-graph.test.mjs', because: 'the resolver must return one thickness per segment for any input: the array drifting ' + 'shorter than the path is the mechanism that produced the whole defect (#234)', patches: [{ file: 'src/wall-face-graph.ts', find: ' for (let i = 0; i < count; i++) {', replace: ' for (let i = 0; i < Math.min(count, (recorded?.length ?? 0)); i++) {', }], }, { id: 'smoke-launcher-skips-freshness', guard: 'node --test --test-name-pattern="launcher enforces the gate" ' + 'test/bundle-freshness.test.mjs', because: 'a smoke run against a stale demo bundle does not fail cleanly — on #234 three ' + 'assertions went red while a fourth went green, because the old code was wrong in two ' + 'places that agreed with each other, and the mixed result reads as a logic defect (#236)', patches: [{ file: 'demo/serve.mjs', find: ' await assertFreshDemoBundleUnlessAllowed(page, repoRoot);', replace: ' // freshness intentionally skipped by the mutant', }], }, { id: 'safe-resize-axis-eligibility-bypassed', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="diagonal and non-perpendicular" test/resize.test.mjs', because: 'an angled wall must never enter the production resize plan; accepting it restores ' + 'the topology-changing path that #277 removes', patches: [{ file: 'src/resize.ts', find: ' const movingAxis = axisOf(a, b, eps);', replace: " const movingAxis = axisOf(a, b, eps) || 'h';", }, { file: 'src/resize.ts', find: " if (!movingAxis) return { enabled: false, reason: 'diagonal' };", replace: " if (false && !movingAxis) return { enabled: false, reason: 'diagonal' };", }], }, { id: 'safe-resize-wall-endpoints-affine-scaled', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 298" test/wall-thickness.test.mjs', because: 'fixed-topology Resize may translate a moving wall or move its topology endpoint, ' + 'but proportional scaling invents a wall-record coordinate with no carrier (#298)', patches: [{ file: 'src/wall-thickness.ts', find: " if (mode === 'fixed-topology') {\n" + ' const adx = move.na[0] - move.oa[0], ady = move.na[1] - move.oa[1];', replace: " if (false && mode === 'fixed-topology') {\n" + ' const adx = move.na[0] - move.oa[0], ady = move.na[1] - move.oa[1];', }], }, { id: 'safe-resize-legacy-midpoint-fail-open', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 298 fixed-topology legacy" ' + 'test/wall-thickness.test.mjs', because: 'Safe Resize must reject an affected key-only midpoint unless it names ' + 'one unambiguous whole changed edge (#298)', patches: [{ file: 'src/wall-thickness.ts', // `false &&` делало ветку статически мёртвой, и TypeScript терял сужение // `direct` внутри неё: мутант падал на компиляции вместо проверки (#568). // Сравнение со строкой вне союза даёт ту же ложь без потери сужения. find: " if (mode === 'fixed-topology') {\n const direct = wholeEdgeMoves.get(w.key);", replace: " if (String(mode) === 'mutant-never-fixed-topology') {\n const direct = wholeEdgeMoves.get(w.key);", }], }, { id: 'safe-resize-third-room-cascade-enabled', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="partial shared and third-owner" test/resize.test.mjs', because: 'a long edge split between neighbours may not turn into a three-room cascade or ' + 'insert vertices; the anonymized #277 regression pins that exact topology', patches: [{ file: 'src/resize.ts', find: " if (partial) return { enabled: false, reason: 'partial-shared' };", replace: " if (false && partial) return { enabled: false, reason: 'partial-shared' };", }], }, { id: 'safe-resize-topology-signature-bypassed', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="exactly two existing vertices" test/resize.test.mjs', because: 'preview and commit must reject a candidate whose vertex count no longer matches ' + 'the gesture plan; otherwise simplify/insert can silently return through another path', patches: [{ file: 'src/resize.ts', find: ' if (!original || !next || next.length !== plan.topology[roomId]\n' + ' || next.length !== original.poly.length || !polyIsSimple(next)) return false;', replace: ' if (!original || !next || !polyIsSimple(next)) return false;', }], }, { id: 'safe-resize-side-ownership-bypassed', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="#289 side ownership" test/resize.test.mjs', because: 'extending or shortening a shared side wall must stop before one thickness record ' + 'describes both shared and outer material; the exact 43-step #289 fixture pins both directions', patches: [{ file: 'src/resize.ts', find: ' if (!sideOwnershipPreserved(result, plan, eps)) return false;', replace: ' if (false && !sideOwnershipPreserved(result, plan, eps)) return false;', }], }, { id: 'safe-resize-opening-jamb-bypassed', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="physical jamb" test/resize.test.mjs', because: 'the moving wall body must stop before a perpendicular opening, including half its ' + 'physical thickness; centreline-only fitting overlaps the jamb', patches: [{ file: 'src/resize.ts', find: ' >= opening.length / 2 + movingHalf - eps;', replace: ' >= -Infinity;', }], }, { id: 'safe-resize-commit-preflight-bypassed', guard: 'node demo/smoke_room_resize.mjs', because: 'a candidate rejected by the exact production wall/floor preflight must create ' + 'neither preview nor commit; polygon-only success previously persisted disappearing walls', patches: [{ file: 'src/resize-controller.ts', find: ' try { valid = input.validatePreview(accepted.preview); } catch { valid = false; }', replace: ' try { valid = true; } catch { valid = false; }', }, { file: 'src/houseplan-editor-runtime.ts', find: ' if (!legacySafe) {', replace: ' if (false && !legacySafe) {', }, { file: 'src/houseplan-editor-runtime.ts', find: ' && this.host._checkSpacePhysicalGeometry(\n' + ' physicalCandidateFor(committedCandidate), before.spaceId,\n' + ' ).ok;', replace: ' && true;', }], }, { id: 'resize-history-boundary-repair-removed', guard: 'node demo/smoke_resize_pointer_real_plan.mjs', because: 'write-time wall degradation must not erase the one Resize Undo command; ' + 'the strict outbound barrier remains authoritative after the local restore (#293)', patches: [{ file: 'src/houseplan-editor-runtime.ts', find: " // A history snapshot can predate the write-time wall degradation that\n" + " // canonicalized its command. Restore that one repairable baseline so\n" + " // Undo remains byte-exact immediately; _writeConfig still degrades and\n" + " // strictly validates the outbound candidate before it can leave the\n" + " // card. Every other preflight failure stays fail-closed.\n" + " safe = !!check?.ok || !!(allowHistoryBoundaryRepair\n" + " && check?.reason === 'wall-degraded-extra');", replace: " safe = !!check?.ok;", }], }, { id: 'resize-audit-resolver-bypassed', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/resize-availability-audit.test.mjs', because: 'the real-plan availability counter must classify every handle through the same ' + 'resolver as production render; an always-enabled audit would make the baseline meaningless (#292)', patches: [{ file: 'src/resize.ts', find: ' const resolution = resolveSafeResize(\n' + ' rooms, openings, room.id, edge, optionsFor(room.id, edge, a, b),\n' + ' );', // Тот же вызов резолвера, но его вердикт игнорируется: подмена литералом // сужала union, и ветка `resolution.reason` ниже становилась `never` — // мутант падал на компиляции вместо теста (#569). replace: ' const resolution = {\n' + ' ...resolveSafeResize(rooms, openings, room.id, edge, optionsFor(room.id, edge, a, b)),\n' + ' enabled: true,\n' + ' } as SafeResizeResolution;', }], }, { id: 'resize-pointer-delta-zeroed', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="#293 pointer displacement" test/resize.test.mjs', because: 'an enabled handle must turn real pointer travel into a signed wall displacement; ' + 'returning zero recreates the active-but-inert user report from #293', patches: [{ file: 'src/resize.ts', find: ' return (current[0] - start[0]) * normal[0]', replace: ' return (current[0] - start[0]) * 0', }], }, { id: 'resize-shared-seam-not-coalesced', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="moving a shared seam" test/wall-thickness.test.mjs', because: 'two owners moving one internal seam must keep the continuous side wall as one ' + 'record; atomising it changes metadata cardinality and silently rejects every preview (#293)', patches: [{ file: 'src/wall-thickness.ts', find: ' if (previous && closePoint(previous[1], atom[0])\n' + ' && collinearForward(previous, atom)) {', replace: ' if (false && previous && closePoint(previous[1], atom[0])\n' + ' && collinearForward(previous, atom)) {', }], }, { id: 'resize-pointer-capture-removed', guard: 'node demo/smoke_resize_pointer_real_plan.mjs', because: 'the real pointer must keep driving the gesture after it leaves the small SVG handle; ' + 'without capture the visible preview freezes as soon as the cursor exits (#293)', patches: [{ file: 'src/houseplan-card.ts', find: ' capturePointer(ev);\n const plan = resolution.plan;', replace: ' // mutant: pointer capture removed\n const plan = resolution.plan;', }], }, { id: 'language-runtime-handwritten-duplicate', guard: 'node --test --test-name-pattern="#354" test/i18n-runtime.test.mjs', because: 'a handwritten twin of LanguageRuntime looks equivalent today and rots tomorrow — ' + 'the i18n suite must prove the object production actually runs (#354 К1/К3)', patches: [{ file: 'src/i18n/registry.ts', find: 'export const LANGUAGE_RUNTIME: LanguageRuntimeContract = new LanguageRuntime(\n' + ' LANGUAGE_REGISTRY,\n' + ' BUILD_FINGERPRINT,\n' + ' console.warn,\n' + ' notifyLanguageLoadFailures,\n' + ');', replace: 'const twin = new LanguageRuntime(LANGUAGE_REGISTRY, BUILD_FINGERPRINT, ' + 'console.warn, notifyLanguageLoadFailures);\n' + 'export const LANGUAGE_RUNTIME: LanguageRuntimeContract = {\n' + ' state: (code) => twin.state(code),\n' + ' dictionary: (code) => twin.dictionary(code),\n' + ' ensure: (code) => twin.ensure(code),\n' + '};', }], }, { id: 'locale-failure-delivery-cut', guard: 'node --test --test-name-pattern="#354" test/i18n-runtime.test.mjs', because: 'cutting the listener loop leaves subscribeLanguageLoadFailures a decoy — the unit ' + 'must prove real code delivery, not only unsubscription (#354 r1-M1)', patches: [{ file: 'src/i18n/registry.ts', find: ' for (const listener of languageLoadFailureListeners) listener(code);', replace: ' void code;', }], }, { id: 'locale-failure-toast-dropped', guard: 'node demo/smoke_german_locale.mjs', because: 'a failed dictionary must be visible on the View card, not only a console line — ' + 'a German user on a stale tab silently reading English is the N7 hole (#354 К2)', patches: [{ file: 'src/houseplan-card.ts', find: " this._languageFailureUnsub = composeUnsub(subscribeLanguageLoadFailures(() => {\n" + " this._showToast(this._t('toast.locale_load_failed'));\n" + ' }), ', replace: ' this._languageFailureUnsub = composeUnsub(', }], }, { id: 'french-locale-wrong-dictionary', guard: 'node demo/smoke_french_locale.mjs', because: 'a French profile silently reading another dictionary is invisible to parity units — ' + 'only the bundle smoke proves the fr entry loads the fr chunk (#371)', patches: [{ file: 'src/i18n/registry.ts', find: " : await import(/* @vite-ignore */ new URL(`${FRENCH_RETRY_ASSET}?retry`, import.meta.url).href);\n return { dictionary: module.dictionary, fingerprint: module.fingerprint };", replace: " : await import(/* @vite-ignore */ new URL(`${FRENCH_RETRY_ASSET}?retry`, import.meta.url).href);\n void module.dictionary;\n return { dictionary: en, fingerprint: module.fingerprint };", }], }, { id: 'i18n-namespace-eager-locale', guard: 'node --test test/i18n-lazy-namespaces.test.mjs', because: 'a static import of one ru/de/fr dictionary of a namespace puts all of it back into ' + 'the static editor and onboarding graphs — every new string costs ×4 again, the exact ' + 'growth #627 removes (13.9 → 34.5 KB gzip of first-run onboarding)', patches: [{ file: 'src/i18n/settings.ts', find: "import en from './settings/en.json' with { type: 'json' };\n", replace: "import en from './settings/en.json' with { type: 'json' };\n" + "import ru from './settings/ru.json' with { type: 'json' };\n", }, { file: 'src/i18n/settings.ts', find: ' SETTINGS_LANGUAGE_RUNTIME.dictionary(lang)?.[key] ?? en[key];', replace: " (lang === 'ru' ? ru[key] : SETTINGS_LANGUAGE_RUNTIME.dictionary(lang)?.[key]) ?? en[key];", }], }, { id: 'locale-gate-ignores-namespaces', guard: 'node --test --test-name-pattern="#627 AC5" test/i18n-runtime.test.mjs', because: 'a host gate that asks only the main catalog paints the open settings dialog in ' + 'English for one frame after a live language switch — the flash #348 forbids, now for ' + 'the lazy namespace dictionaries (#627 AC5)', patches: [{ file: 'src/i18n/namespace-language.ts', find: ' state: (code) => {\n' + ' const state = primary.state(code);\n' + " return state !== 'pending' && namespaces.some((runtime) => runtime.state(code) === 'pending')\n" + " ? 'pending' : state;\n" + ' },', replace: ' state: (code) => primary.state(code),', }], }, { id: 'onboarding-loader-skips-namespace-ensure', guard: 'node demo/smoke_lazy_admin_locale.mjs', because: 'an onboarding runtime installed before its settings dictionary settled forces the ' + 'host into the warm branch: the first-run form waits behind an inert, busy card instead ' + 'of the existing runtime indicator — a new interface state the contract rules out (#627 AC4)', patches: [{ file: 'src/houseplan-card.ts', find: ' if (module.ONBOARDING_RUNTIME_FINGERPRINT === ENTRY_BUILD_FINGERPRINT) await module.ONBOARDING_LANGUAGE_RUNTIME.ensure(langOf(this.hass, this._config?.language)); // #627\n', replace: '', }], }, { id: 'namespace-loader-returns-english', guard: 'node demo/smoke_lazy_admin_locale.mjs', because: 'a German administrator silently reading the English settings dialog is invisible ' + 'to parity units — only the bundle smoke proves the de loader commits the de chunk (#627 AC6)', patches: [{ file: 'src/i18n/settings.ts', find: " : import(/* @vite-ignore */ retryUrl('__HOUSEPLAN_SETTINGS_DE_RETRY_ASSET__'))),\n", replace: " : import(/* @vite-ignore */ retryUrl('__HOUSEPLAN_SETTINGS_DE_RETRY_ASSET__')))\n" + ' .then((module) => ({ dictionary: en, fingerprint: module.fingerprint })),\n', }], }, { id: 'static-glow-light-cache-spread', guard: 'node --test test/space-render-caches.test.mjs', because: 'a fresh spread on the devices array silently defeats RESOLVED_LIGHT_CACHE ' + '(WeakMap by array identity) in BOTH cards — invisible to CI perf profiles (#375 V6a)', patches: [{ file: 'src/glow-scene.ts', find: " input.hass, input.devices, null, input.virtualLights,", replace: " input.hass, [...input.devices], null, input.virtualLights,", }], }, { id: 'static-glow-scene-lru-single', guard: 'node --test test/space-render-caches.test.mjs', because: 'a single-entry scene cache makes a door ping-pong rebuild the barrier scene ' + 'on every flip — the LRU capacity is the contract, parity with the full card (#375 V6c)', patches: [{ file: 'src/space-render.ts', find: "const STATIC_LIGHT_BARRIER_LRU = 8;", replace: "const STATIC_LIGHT_BARRIER_LRU = 1;", }], }, { id: 'decor-default-style-seed-cut', guard: 'node demo/smoke_decor_default_persist.mjs', because: 'without the seed the persisted key is dead weight: every reload silently falls ' + 'back to the factory style while the write path keeps pretending to save (#377)', patches: [{ file: 'src/houseplan-card.ts', find: " if (raw) this._decorStyle = decorStyleFromSettings(raw, DEFAULT_DECOR_STYLE);", replace: " void raw;", }], }, { id: 'decor-default-style-debounce-cut', guard: 'node demo/smoke_decor_default_persist.mjs', because: 'without the debounce a palette drag streams a config write per input event — ' + 'store spam and needless expected_rev races between tabs (#377)', patches: [{ file: 'src/houseplan-editor-runtime.ts', find: " this._persistDecorStyle();\n }, 1000);", replace: " this._persistDecorStyle();\n }, 0);", }], }, { id: 'core-budget-ignores-growth', guard: 'node --test test/core-file-budget.test.mjs', because: 'the two cores put on 500-1000 lines per release while everything new ' + 'goes into new modules — a budget that does not notice growth of its own ' + 'files is decoration, and the decomposition issue it replaces sat still for ' + '25 days precisely because nobody was counting (#425, replaces #34)', patches: [{ file: 'src/houseplan-card.ts', find: 'export class HouseplanCard', replace: `${'\n'.repeat(400)}export class HouseplanCard`, }], }, { id: 'capture-allows-partial-raster', guard: 'node --test test/capture-determinism-args.test.mjs', because: 'partial raster reuses whatever the compositor drew before, so the frame ' + 'depends on the history of the run — one or two of ten frames drifted between ' + 'runs while three shots inside one process matched byte for byte (#424)', patches: [{ file: 'demo/docs/browser-args.mjs', find: " '--disable-partial-raster',\n", replace: '', }], }, { id: 'capture-draws-before-compositor-settles', guard: 'node --test test/capture-determinism-args.test.mjs', because: 'without running every compositor stage first the screenshot is taken ' + 'mid-flight, and the remaining work lands differently on every run (#424)', patches: [{ file: 'demo/docs/browser-args.mjs', find: " '--run-all-compositor-stages-before-draw',\n", replace: '', }], }, { id: 'capture-gate-forgives-a-missing-frame', guard: 'node --test test/capture-determinism-gate.test.mjs', because: 'comparing only the frames both runs happen to have lets a run that ' + 'produced nine frames pass against a run that produced ten — «совпало» then ' + 'means nothing, which is the opposite of what the gate is for (#422)', patches: [{ file: 'scripts/capture-determinism.mjs', find: ' const names = [...new Set([...Object.keys(first), ...Object.keys(second)])].sort();', replace: ' const names = Object.keys(first).filter((name) => name in second).sort();', }], }, { id: 'capture-gate-hashes-everything-in-the-folder', guard: 'node --test test/capture-determinism-gate.test.mjs', because: 'hashing every file in docs/images drags the manifest into the comparison, ' + 'and the manifest legitimately changes between runs — the gate would then cry ' + 'wolf on every capture and be switched off within a week (#422)', patches: [{ file: 'scripts/capture-determinism.mjs', find: " if (!name.endsWith('.png')) continue;\n", replace: '', }], }, { id: 'capture-drifts-between-runs', guard: 'node --test test/capture-clip.test.mjs', because: 'a crop that rounds to the nearest pixel shaves half a pixel off the ' + 'target, and the frame then depends on sub-pixel layout — the very drift ' + 'that made #410 undiagnosable; the in-process stability probe stays green ' + 'on it, so the whole-pixel rule needs a check of its own (#422)', patches: [{ file: 'demo/docs/clip.mjs', find: ' const x = Math.floor(rect.x);', replace: ' const x = Math.round(rect.x);', }], }, { id: 'anchor-liveness-ignores-reachability', guard: 'node --test test/review-doc-guard.test.mjs', because: 'asking whether the object merely exists locally lets an anchor born on ' + 'the author machine soften the #413 refusal exactly where the mistake was ' + 'made — liveness has to mean reachable from origin (#422)', patches: [{ file: 'scripts/review-doc-guard.mjs', find: " const probe = run(['log', '--remotes=origin', '--tags', '--format=%T']);", replace: " const probe = { status: 0, stdout: object };", }], }, { id: 'danger-confirm-back-into-the-branch', guard: 'node demo/smoke_danger_confirm_branches.mjs', because: 'a confirmation that lives inside one branch of render() does not ' + 'exist in onboarding — the trash button was dead and the promise hung ' + 'forever (#402)', patches: [{ file: 'src/houseplan-card.ts', find: ' return html`${body}${this._renderVersionBanner()}${this._renderDangerConfirm()}`;', replace: ' return html`${body}${this._renderVersionBanner()}`;', }], }, { id: 'danger-confirm-lost-space-request-guard-removed', guard: 'node demo/smoke_danger_confirm_branches.mjs', because: 'accepting a new confirmation while the active space resolves to nothing ' + 'registers a promise whose hp-confirm owner cannot exist (#417 AC1)', patches: [{ file: 'src/houseplan-card.ts', find: " if (this._dangerConfirmMissingSpace()) return Promise.resolve(false);", replace: " void this._dangerConfirmMissingSpace();", }], }, { id: 'danger-confirm-lost-space-transition-cancel-removed', guard: 'node demo/smoke_danger_confirm_branches.mjs', because: 'an open confirmation must resolve false before a lost-space render clears ' + 'its only decision source (#417 AC1)', patches: [{ file: 'src/houseplan-card.ts', find: ' if (this._dangerConfirm && (\n' + ' this._dangerConfirmMissingSpace() || this._syncDangerConfirmLocaleGate() === \'warm\'\n' + ' )) {\n' + ' this._cancelDangerConfirm();\n' + ' }', replace: ' if (this._dangerConfirm && this._syncDangerConfirmLocaleGate() === \'warm\') {\n' + ' this._cancelDangerConfirm();\n' + ' }', }], }, { id: 'danger-confirm-warm-language-guard-removed', guard: 'node demo/smoke_danger_confirm_branches.mjs', because: 'a request made while the language gate is already warm must be rejected ' + 'synchronously without ever entering the confirmation controller (#417 AC2/AC3)', patches: [{ file: 'src/houseplan-card.ts', find: " if (this._syncDangerConfirmLocaleGate() === 'warm') return Promise.resolve(false);", replace: " void this._syncDangerConfirmLocaleGate();", }], }, { id: 'danger-confirm-uses-last-rendered-language-gate', guard: 'node demo/smoke_danger_confirm_branches.mjs', because: 'ready -> warm and warm -> ready both have a window before render; consulting the ' + 'last painted branch can admit an unrenderable request or reject a renderable one (#434 AC7)', patches: [{ file: 'src/houseplan-card.ts', find: " /** Synchronize host/runtime language state and return the current branch. */\n" + ' private _syncDangerConfirmLocaleGate(): LanguageRenderGate {\n' + " if (!this._config || !this.hass) return 'ready';\n" + ' return languageRenderGate( // #627: + dictionaries of the surfaces loaded on THIS host\n' + ' this, this._editorRuntime?.languageRuntime ?? this._onboardingRuntime?.languageRuntime ?? LANGUAGE_RUNTIME,\n' + ' langOf(this.hass, this._config.language),\n' + ' );\n' + ' }', replace: " private _dangerConfirmLocaleGate: LanguageRenderGate = 'ready';\n" + ' private _syncDangerConfirmLocaleGate(): LanguageRenderGate {\n' + ' return this._dangerConfirmLocaleGate;\n' + ' }', }, { file: 'src/houseplan-card.ts', find: ' const localeGate = this._syncDangerConfirmLocaleGate();\n' + " if (localeGate === 'cold') return languageLoadingTemplate();", replace: ' const localeGate = languageRenderGate(\n' + ' this, this._editorRuntime?.languageRuntime ?? this._onboardingRuntime?.languageRuntime ?? LANGUAGE_RUNTIME,\n' + ' langOf(this.hass, this._config.language),\n' + ' );\n' + ' this._dangerConfirmLocaleGate = localeGate;\n' + " if (localeGate === 'cold') return languageLoadingTemplate();", }], }, { id: 'danger-confirm-warm-transition-cancel-removed', guard: 'node demo/smoke_danger_confirm_branches.mjs', because: 'a dialog opened while ready must settle false and lose its actionable DOM owner ' + 'when a new language moves the committed card into the warm noChange branch (#434 AC7)', patches: [{ file: 'src/houseplan-card.ts', find: ' if (this._dangerConfirm && (\n' + ' this._dangerConfirmMissingSpace() || this._syncDangerConfirmLocaleGate() === \'warm\'\n' + ' )) {\n' + ' this._cancelDangerConfirm();\n' + ' }', replace: ' if (this._dangerConfirm && this._dangerConfirmMissingSpace()) {\n' + ' this._cancelDangerConfirm();\n' + ' }', }], }, { id: 'space-card-decor-capability-downgrade-does-not-clear-assets', guard: 'node demo/smoke_space_card_decor_capability.mjs', because: 'the static card must fail closed after a capability-only backend downgrade; ' + 'source-shape assertions cannot prove that the stale image map is actually cleared (#440)', patches: [{ file: 'src/space-card.ts', find: ' if (snap.decorAssetsApi !== DECOR_ASSETS_API_VERSION) {', replace: ' if (false) {', }], }, { id: 'space-card-decor-capability-change-not-adopted', guard: 'node demo/smoke_space_card_decor_capability.mjs', because: 'an exact capability-only upgrade must become authoritative even when config, ' + 'layout and virtual-light content are unchanged (#440)', patches: [{ file: 'src/space-card.ts', find: ' this._snap = snap;\n', replace: ' this._snap = { ...snap, decorAssetsApi: this._snap?.decorAssetsApi ?? null };\n', }], }, { id: 'furniture-edge-handles-steal-the-corner', guard: 'node demo/smoke_furniture_polish.mjs', because: 'both handles share one hit radius, so on furniture narrower than ' + '4·hr whichever is painted last takes the corner — and the corner is ' + 'the one that cannot be reached any other way (#400 AC1)', patches: [{ file: 'src/houseplan-card.ts', // Порядок — именованное решение, поэтому мутант меняет ЕГО, а не текст // рядом с ним: прежний патч правил комментарий и регрессию не // воспроизводил (находка CODE-REVIEW-400-r1). find: "const HANDLE_PAINT_ORDER = ['edges', 'corners'] as const;", replace: "const HANDLE_PAINT_ORDER = ['corners', 'edges'] as const;", }], }, { id: 'daycycle-outline-promoted-on-inner-paper', guard: 'node demo/smoke_daycycle_layer_budget.mjs', because: '#582: if camera movement fails to demote the inner paper group, a 4700x4200 ' + 'floor keeps texture-sized overlap layers and HA Companion loses tiles', patches: [{ file: 'src/styles/plan.styles.ts', find: ' .stage.daycycle.hp-safe-daycycle-outline .hp-paperg {\n' + ' filter: none;\n' + ' will-change: auto;\n', replace: ' .stage.daycycle.hp-safe-daycycle-outline .hp-paperg {\n' + ' will-change: filter;\n', }], }, { id: 'daycycle-static-outline-promoted-on-inner-paper', guard: 'node demo/smoke_daycycle_layer_budget.mjs', because: '#582 contract item 6: the static card has no gesture that could later demote ' + 'a coordinate-sized filtered paper group, so it must start with the bounded sibling', patches: [{ file: 'src/styles/plan.styles.ts', find: ' .hp-static-stage.daycycle .hp-paperg {\n' + ' filter: none;\n' + ' will-change: auto;\n' + ' transition: none;\n', replace: ' .hp-static-stage.daycycle .hp-paperg {\n' + ' will-change: filter;\n', }], }, { id: 'daycycle-programmatic-camera-skips-safe-outline', guard: 'node demo/smoke_smooth_zoom.mjs', because: '#582: double-tap, fit, room focus, wheel and button zoom animate the same ' + 'camera as pinch and must enter the bounded compositor topology before movement', patches: [{ file: 'src/houseplan-card.ts', find: ' this._activateSafeDayCycleOutline();\n' + ' this._cameraTransitionFit = { ...fit };\n', replace: ' this._cameraTransitionFit = { ...fit };\n', }], }, { id: 'daycycle-outline-not-promoted', guard: 'node demo/smoke_daycycle_raster.mjs', because: 'the day-cycle outline is a triple drop-shadow over the whole sheet; without its own ' + 'compositing layer every repaint of the plan re-runs three blur passes, and a demo-stand ' + 'pan costs fifteen times the rasterization of a static background (#532 AC1/AC2)', patches: [{ file: 'src/styles/plan.styles.ts', find: ' will-change: filter;\n', replace: '', }], }, { id: 'version-banner-disconnect-silent', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="#536 a notice dropped" test/version-recovery.test.mjs', because: 'Lit renders neither on disconnect nor on reconnect, so a notice dropped in silence ' + 'stays on screen with no owner until some unrelated update happens to run — correctness ' + 'by coincidence, which is exactly what #536 removed', patches: [{ file: 'src/version-recovery.ts', find: ' if (this._banner) {\n' + ' this._banner = null;\n' + ' this.hooks.changed();\n' + ' }', replace: ' if (this._banner) this._banner = null;', }], }, { id: 'align-guides-exclude-dead-source', guard: 'node demo/smoke_align_guides.mjs', because: 'device dragging lives in _deviceDrag since #74; excluding by ' + '_drag excludes nothing, and a guide drawn from a marker to itself ' + 'looks exactly like an honest one (#400 AC4/AC5)', patches: [{ file: 'src/houseplan-editor-runtime.ts', find: ' const draggedId = this.host._deviceDrag?.id ?? this.host._drag?.id;', replace: ' const draggedId = this.host._drag?.id;', }], }, { id: 'workflow-scan-hardcodes-the-list', guard: 'node --test test/validate-workflow.test.mjs', because: 'scanning a fixed pair of names is how a third workflow installs ' + 'unpinned dependencies unnoticed — the shape #399 removed', patches: [{ file: 'test/validate-workflow.test.mjs', // Список из ДВУХ настоящих имён — самая правдоподобная форма регрессии: // на сегодняшнем дереве она неотличима от корректного кода, и мутант с // одним именем ловился бы по посторонней причине (замечание r1). find: " const files = readdirSync(directory).filter((name) => name.endsWith('.yml'));", replace: " const files = ['validate.yml', 'mutation-gate.yml'];", }], }, { id: 'lint-scope-drifts', guard: 'node --test test/lint-scope.test.mjs', because: 'a declared lint scope wider than the checked one is what let ' + 'F401/F811 into tests_backend unnoticed (#399)', patches: [{ file: 'pyproject.toml', find: 'include = ["custom_components/houseplan/**/*.py"]', replace: 'include = ["custom_components/houseplan/**/*.py", "tests_backend/**/*.py"]', }], }, { id: 'frontend-pin-drifts-from-ha', guard: 'node --test test/backend-pins.test.mjs', because: 'a frontend pin nobody derives from the pinned HA is how the ' + 'harness ends up testing a combination that does not exist (#399)', patches: [{ file: 'tests_backend/requirements.txt', find: 'home-assistant-frontend==20260729.7', replace: 'home-assistant-frontend==20260826.1', }], }, { id: 'sysmodules-guard-blind-to-variable', guard: 'node --test test/backend-test-hygiene.test.mjs', because: 'a guard that only sees string literals let the third instance of ' + 'the #389 class walk past it (#398 AC1)', patches: [{ file: 'test/backend-test-hygiene.test.mjs', find: ' if (start === null) { if (namesThePackage) hits.push(match[0]); continue; }', replace: ' if (start === null) { continue; }', }], }, { id: 'pure-imports-stops-cleaning', guard: 'python3 -m pytest tests_backend/test_backend_quality.py -q -p no:cacheprovider', because: 'pure_imports is allowed to write into sys.modules only because it ' + 'removes the entries again; without that the exemption is a hole (#398 AC8)', patches: [{ file: 'tests_backend/pure_imports.py', find: ' del sys.modules[key]', replace: ' pass', }], }, { id: 'golden-accept-sandbox-leaks', guard: 'node --test test/temp-dir-hygiene.test.mjs', because: 'the ~20 MB baselines sandbox of accept() outliving every call is what filled the ' + 'shared TMPDIR to 8.5 GB and ENOSPC (#646 AC2)', patches: [{ file: 'test/golden-capture-provenance.test.mjs', find: ' rmSync(sandbox, { recursive: true, force: true });', replace: ' // sandbox is left for inspection', }], }, { id: 'temp-hygiene-accepts-cleanup-outside-finally', guard: 'node --test test/temp-dir-hygiene.test.mjs', because: 'a trailing rmSync after the asserts does not run when an assert fails, so a red ' + 'test still leaks; the lint must not count it as cleanup (#646 AC2)', patches: [{ file: 'test/temp-dir-hygiene.test.mjs', find: 'function inCleanupPosition(node, scope) {', replace: 'function inCleanupPosition(node, scope) {\n if (node) return true;', }], }, { id: 'ha-harness-notice-silent', guard: 'python3 -m pytest tests_backend/test_conftest_harness_notice.py -q -p no:cacheprovider', because: 'without Home Assistant test_ha_*.py are not collected at all; a run that ' + 'does not say so in its -q summary looks like a full backend check (#630 AC1)', patches: [{ file: 'tests_backend/conftest.py', find: ' terminalreporter.write_line(ha_harness_warning(*HA_HARNESS_IGNORED), yellow=True)', replace: ' pass', }], }, { id: 'ha-harness-notice-count-frozen', guard: 'python3 -m pytest tests_backend/test_conftest_harness_notice.py -q -p no:cacheprovider', because: 'the number of ignored HA tests must follow the files, not a figure copied ' + 'from one audit — a frozen count drifts the day a harness test is added (#630 AC1)', patches: [{ file: 'tests_backend/conftest.py', find: ' tests = sum(len(_TEST_DEF.findall(path.read_text(encoding="utf-8"))) for path in files)', replace: ' tests = 292', }], }, { id: 'device-echo-keeps-local-noncanonical', guard: 'node demo/smoke_device_position_history.mjs', because: 'a card that keeps the raw position while sending the canonical ' + 'one mistakes its own echo for a remote edit and wipes the undo stack ' + '(#397 B3)', patches: [{ file: 'src/houseplan-card.ts', find: ' this._layout = { ...this._layout, [deviceId]: pos };', replace: ' void pos;', }], }, { id: 'camera-cancel-loses-zoom', guard: 'node demo/smoke_smooth_zoom.mjs', because: 'the frame frozen by touching the plan is what the user sees; not ' + 'persisting it is the v1.69.0 regression #396 B1 closes', patches: [{ file: 'src/houseplan-card.ts', find: ' this._cancelCameraTransition(false, true);', replace: ' this._cancelCameraTransition(false);', }], }, { id: 'room-fit-interactive-owner-leaks-through', guard: 'node demo/smoke_room_fit.mjs', because: 'an HA link or another independently interactive child must never bubble into ' + 'the room camera command (#152 AC6)', patches: [{ file: 'src/room-fit.ts', find: " if (pathMatches(node, ROOM_FIT_INTERACTIVE_OWNER)) return { kind: 'interactive' };", replace: " if (pathMatches(node, ROOM_FIT_INTERACTIVE_OWNER)) continue;", }], }, { id: 'room-fit-pan-release-reaccepted', guard: 'node demo/smoke_room_fit.mjs', because: 'movement that became a pan must not survive as a room click when the pointer ' + 'is released over the original room (#152 AC7)', patches: [{ file: 'src/houseplan-card.ts', find: ' this._roomPointer = null;\n this._doubleFit.clear();\n this._suppressClick = true;', replace: ' this._doubleFit.clear();\n this._suppressClick = true;', }, { file: 'src/room-fit.ts', find: ' if (!candidate || blocked || candidate.pointerId !== pointerId', replace: ' if (!candidate || candidate.pointerId !== pointerId', }], }, { id: 'room-fit-html-overlay-jumps-ahead', guard: 'node demo/smoke_room_fit.mjs', because: 'HTML room labels and the SVG plan must consume the same presented camera on ' + 'every intermediate transition frame (#152 AC10)', patches: [{ file: 'src/houseplan-card.ts', find: 'space.rooms.map((r) => this._renderRoomLabel(\n' + ' r, space, view, disp, isoOverlays?.rooms.get(r),\n' + ' ))', replace: 'space.rooms.map((r) => this._renderRoomLabel(\n' + ' r, space, this._cameraTransition.target?.viewBox || view, disp, ' + 'isoOverlays?.rooms.get(r),\n' + ' ))', }], }, { id: 'room-fit-persists-zoom', guard: 'node demo/smoke_room_fit.mjs', because: 'room focus is session-only and must not leak its zoom into LS_ZOOM on ' + 'completion or cancellation (#152 AC15)', patches: [{ file: 'src/houseplan-card.ts', find: " if (state.reason !== 'room') this._saveZoom();", replace: ' this._saveZoom();', }, { file: 'src/houseplan-card.ts', find: " if (presentedZoom !== undefined && reason !== 'room') this._saveZoom();", replace: ' if (presentedZoom !== undefined) this._saveZoom();', }], }, { id: 'camera-anchor-from-presented', guard: 'node demo/smoke_smooth_zoom.mjs', because: 'reading the anchor from the lagging frame walks the point under ' + 'the pointer 17 px away in a fast wheel series (#396 B2)', patches: [{ file: 'src/houseplan-card.ts', find: ' const anchorFrom = (animated && this._cameraTransition.target)', replace: ' const anchorFrom = (false && this._cameraTransition.target)', }], }, { id: 'glow-feather-thaws-during-camera', guard: 'node --test test/golden-matrix.test.mjs', because: 'the feather freeze must know about the animated transition, not ' + 'only about pinch and pan (#396 M2)', patches: [{ file: 'src/houseplan-card.ts', find: ' && !this._cameraTransition.active;', replace: ' && true;', }], }, { id: 'error-code-dropped-from-contract', guard: 'python3 -m pytest tests_backend/test_backend_quality.py -q -p no:cacheprovider', because: 'a code emitted by the backend but missing from ERROR_CODES is exactly the ' + 'unregistered-error hole #42 closes (AC5 m1)', patches: [{ file: 'custom_components/houseplan/const.py', find: ' "invalid_space_id", "invalid_toggle_entity", "invalid_vacuum_map_route",', replace: ' "invalid_toggle_entity", "invalid_vacuum_map_route",', }], }, { id: 'typing-gate-stops-running', guard: 'python3 -m pytest tests_backend/test_backend_quality.py -q -p no:cacheprovider', because: 'a strict-typing allowlist that CI never executes is a measurement ' + 'that measures nothing — the step must be load-bearing (#42 r6 AC4)', patches: [{ file: '.github/workflows/validate.yml', find: ' python -m mypy $modules', replace: ' echo "skip: $modules"', }], }, { id: 'error-scanner-loses-a-class-source', guard: 'python3 -m pytest tests_backend/test_backend_quality.py -q -p no:cacheprovider', because: 'silencing one err.code class source must redden the scanner, or the branch ' + '(b) proof is decorative (#42 AC5 m1b)', patches: [{ file: 'tests_backend/test_backend_quality.py', find: " fixed |= set(re.findall(r'^\\s+code = \"([a-z0-9_]+)\"', validation, re.M))", replace: " pass # m1b: the class-attr source silenced", }], }, { id: 'error-code-via-variable-dropped', guard: 'python3 -m pytest tests_backend/test_backend_quality.py -q -p no:cacheprovider', because: 'the variable-passed MarkerControlError subfamily must stay proven — dropping ' + 'invalid_light_entity from ERROR_CODES has to redden AC5 (#42 m1c, spec rev6)', patches: [{ file: 'custom_components/houseplan/const.py', find: ' "invalid_light_entity", "invalid_marker_control", "invalid_name",', replace: ' "invalid_marker_control", "invalid_name",', }], }, { id: 'error-details-json-branch-cut', guard: 'node --test test/open-passage-contract.test.mjs', because: 'structured JSON details replacing the English-sentence regex is the visible ' + 'half of the #42 error contract (AC6 m2)', patches: [{ file: 'src/houseplan-card.ts', find: " let spaceId = '', fieldList: string[] = [];\n try {\n const details = JSON.parse(raw);", replace: " let spaceId = '', fieldList: string[] = [];\n try {\n const details = { space: null, fields: null }; void raw;", }], }, { id: 'discovery-preview-copies-the-filter', guard: 'node --test test/devices.test.mjs', because: 'the preview must diff the REAL seedHiddenBindings/buildDevices outputs — a ' + 'hand-rolled platform check would drift from the production filter (#44 AC6)', patches: [{ file: 'src/houseplan-editor-runtime.ts', find: " const seededOf = (settings: object, excluded: ReadonlySet) => new Set(\n seedHiddenBindings({ ...ctx, settings, excluded } as never));", replace: " const seededOf = (settings: object, excluded: ReadonlySet) => new Set(\n [...excluded].filter((platform) => platform && EXCLUDED_DOMAINS.has(platform)));", }], }, { id: 'discovery-reset-writes-a-copy', guard: 'node demo/smoke_discovery_filters.mjs', because: 'Restore recommended must store the default as ABSENCE of the key — a written ' + 'copy of the product list would freeze it against future product updates (#44 AC2)', patches: [{ file: 'src/houseplan-editor-runtime.ts', find: " if (draft.usesProductList) delete settings.exclude_integrations;\n else settings.exclude_integrations = draft.excluded;", replace: " settings.exclude_integrations = draft.excluded;", }], }, { id: 'schema-manifest-enum-drift', guard: 'node --test test/config-schema-parity.test.mjs', because: 'a backend schema path the frontend does not know (and the allow-list does not ' + 'bless) is exactly the schema drift #33 exists to catch; a single-line path mutation ' + 'also stays deterministic across LF and CRLF schema dumps', patches: [{ file: 'scripts/config-schema.json', find: ' "config.spaces[].settings.fill_mode": {', replace: ' "config.spaces[].settings.fill_mode_phantom": {', }], }, { id: 'registry-selector-dead-decision', guard: 'node --test test/config-schema-parity.test.mjs', because: 'a registry decision whose selector no longer matches the schema is a dead ' + 'decision — the completeness test must refuse to let them accumulate (#33 AC4)', patches: [{ file: 'scripts/config-field-registry.mjs', find: " selector: { path: ['spaces', '*', 'zero_wall_style'] },", replace: " selector: { path: ['spaces', '*', 'zero_wall_stylo'] },", }], }, { id: 'same-binding-click-resets-source', guard: 'node demo/smoke_value_face_source.mjs', because: 'a same-binding click silently wiping the configured value source is exactly ' + 'the #385(a) bug — only the dialog smoke drives the real handler', patches: [{ // #592: диалог устройства переехал в свой модуль; #600: выбор кандидата — // обработчик onPick кнопки выбора набора, охрана та же. file: 'src/editors/marker-dialog.ts', find: " if (value === d.binding) {\n this.host._markerDialog = { ...d, bindingOpen: false };\n return;\n }", replace: " if (value === d.binding && d.bindingOpen === false) {\n this.host._markerDialog = { ...d, bindingOpen: false };\n return;\n }", }], }, { id: 'release-proof-computed-for-every-commit', guard: 'node --test test/process-gate.test.mjs', because: 'gating the expensive diff proof on the SAME release predicate is the #385(v) ' + 'contract — a narrowed or removed gate either wastes git-show spawns per commit ' + 'or hands release commits a null proof (false violations)', patches: [{ file: 'scripts/process-gate.mjs', find: " releaseSourceViolations: isReleaseCommit(subject, one)\n ? releaseSourceViolationsOf(sha, files) : null,", replace: " releaseSourceViolations: releaseSourceViolationsOf(sha, files),", }], }, { id: 'task-packet-product-flow-overrides-diff', guard: 'node --test test/task-packet.test.mjs', because: 'a product S6 issue whose branch holds no class A yet must keep its class A rights; ' + 'letting the diff heuristic win again prints the false infrastructure ban of #632', patches: [{ file: 'scripts/task-packet.mjs', find: " const infrastructure = branch?.infrastructure === true && productFlow.length === 0;", replace: " const infrastructure = branch?.infrastructure === true;", }], }, { id: 'task-packet-review-docs-not-material', guard: 'node --test test/task-packet.test.mjs', because: 'docs/reviews/** is written by the pipeline, not by the task: counting it as ' + 'material makes a spec-review-only product branch look like infrastructure (#632)', patches: [{ file: 'scripts/task-packet.mjs', find: " const material = changedFiles.filter((name) => !name.startsWith('docs/reviews/'));", replace: " const material = changedFiles;", }], }, { id: 'task-packet-s6-s7-alone-not-product-flow', guard: 'node --test test/task-packet.test.mjs', because: 'infrastructure issues enter at S7 and return to S6, so S6/S7 alone must not ' + 'count as product flow — otherwise a returned infra task loses the class A ban (#632)', patches: [{ file: 'scripts/task-packet.mjs', find: " if (PRE_CODE_STATUSES.includes(status)) reasons.push(`статус ${status}`);", replace: " if (STATUS_LABELS.includes(status)) reasons.push(`статус ${status}`);", }], }, { 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 ' + '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)');", replace: "", }], }, { id: 'iso-room-label-44-box-centres-name', guard: 'node demo/smoke_iso_room_label_metrics.mjs', because: '#665: sizing the raised label box to 44 px centres the name in it and pushes the metrics row away by (44 - name) / 2, so the gap drifts with zoom', patches: [{ file: 'src/styles/plan.styles.ts', find: " .stage.projection-iso.mode-view .roomlabel {\n box-sizing: border-box;\n", replace: " .stage.projection-iso.mode-view .roomlabel {\n box-sizing: border-box;\n min-height: 44px;\n justify-content: center;\n", }], }, { id: 'static-card-descendants-hit-testable', guard: 'node demo/smoke_space_card.mjs', because: '#664: pointer-events:none on the stage does not stop a descendant that opts back in; ' + 'without the subtree rule the shared #564 capsule shows a pointer cursor and swallows clicks', patches: [{ file: 'src/space-card.ts', find: " .hp-static-stage *,\n .hp-static-stage *::before,\n .hp-static-stage *::after {\n pointer-events: none;\n }\n", replace: "", }], }, { id: 'fit-house-hidden-walls-vote', guard: 'node demo/smoke_space_card.mjs', because: 'hidden architecture silently widening the tight frame is exactly the #384 bug: ' + 'only the twin-frames smoke sees the rendered viewBox difference', patches: [{ file: 'src/space-render.ts', find: " if (disp.showBorders) for (const body of extras) {", replace: " for (const body of extras) {", }], }, { id: 'space-card-null-title-compact-narrowed', guard: 'node --test test/space-card-audit-lows.test.mjs', because: 'YAML `title:` with no value is null — narrowing the compact condition back to ' + "'' alone silently reopens the #372 header strip for those configs (#376а)", patches: [{ file: 'src/space-card.ts', find: " compactTopFrame: this._config.title === '' || this._config.title === null,", replace: " compactTopFrame: this._config.title === '',", }], }, { id: 'furniture-stroke-iso-constant-returns', guard: 'node --test test/furniture-stroke-contract.test.mjs', because: '#649 3a: the 2.5D View must use the Flat camera rule for furniture strokes; the ' + 'former iso constant 1 made furniture several times thicker there (attachments 14/15)', patches: [{ file: 'src/houseplan-card.ts', find: " const furnitureScreenScale = furniturePlanScreenScale(", replace: " const furnitureScreenScale = this._renderProjection === 'iso' ? 1 : furniturePlanScreenScale(", }], }, // #649 Stage 6 (docs/ISOMETRIC.md): tiles, shadows, frames, sun, walls, setting. { id: 'iso-tile-ring-visible', guard: 'node demo/smoke_iso_tiles.mjs', because: '#649 AC2: a 2.5D tile has no ring; the Flat shell ring must not show through', patches: [{ file: 'src/styles/iso-tiles.styles.ts', find: ' .stage.projection-iso.mode-view .dev .device-shell-frame {\n border-color: transparent;', replace: ' .stage.projection-iso.mode-view .dev .device-shell-frame-mutant {\n border-color: transparent;', }], }, { id: 'iso-tile-edge-zero', guard: 'node demo/smoke_iso_tiles.mjs', because: '#649 AC2: the tile edge is 0.1 D straight down (lab ICON_TILE_DEPTH 8 of 80)', patches: [{ file: 'src/iso-tiles.ts', find: ' depth: 8 / 80,', replace: ' depth: 0,' }], }, { id: 'iso-tile-badge-gap-dropped', guard: 'node demo/smoke_iso_tiles.mjs', because: '#649 AC2: a value badge sits 0.075 D from its tile, not at the Flat gap', patches: [{ file: 'src/iso-tiles.ts', find: '${S} .dev .device-shell.with-values { gap:', replace: '${S} .dev .device-shell.with-values-mutant { gap:', }], }, { id: 'iso-collision-without-icon-scale', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="AC3 layout" test/iso-stage6.test.mjs', because: '#649 AC3: layout and collisions must see the 1.12 tile, otherwise raised tiles overlap', patches: [{ file: 'src/iso-scene-render.ts', find: ' const core = baseDeviceUnits * presentation.scale * ISO_ICON_SCALE;', replace: ' const core = baseDeviceUnits * presentation.scale;', }], }, { id: 'iso-tile-shadow-layer-above', guard: 'node demo/smoke_iso_tiles.mjs', because: '#649 AC4: floor shadows live in one layer under every marker; above them a shadow ' + 'lands on the neighbour tile (attachment 09, Kitchen & Living column)', patches: [{ file: 'src/styles/iso-tiles.styles.ts', find: ' pointer-events: none;\n z-index: -1;', replace: ' pointer-events: none;\n z-index: 10;', }], }, { id: 'iso-frame-selected-beats-focus', guard: 'node demo/smoke_iso_tiles.mjs', because: '#649 AC5: frame priority is Alert > Focus > Selected > Hover (icon package SPECIFICATION.md)', patches: [{ file: 'src/styles/iso-tiles.styles.ts', find: '.dev.sel:not(:focus-visible) { --iso-frame: #F0A00C; }', replace: '.dev.sel.sel.sel { --iso-frame: #F0A00C; }', }], }, { id: 'iso-shadow-in-forced-colors', guard: 'node demo/smoke_iso_tiles.mjs', because: '#649 AC6: forced colours draw no edge and no floor shadow', patches: [{ file: 'src/styles/iso-tiles.styles.ts', find: ' @media (forced-colors: active) {\n .stage.projection-iso.mode-view .iso-tile-shadows { display: none; }', replace: ' @media (forced-colors: active) {\n .stage.projection-iso.mode-view .iso-tile-shadows-mutant { display: none; }', }], }, { id: 'iso-sun-length-ignores-elevation', guard: 'node demo/smoke_iso_sun.mjs', because: '#649 AC7: the beam length follows the elevation (low sun = long beam)', patches: [{ file: 'src/iso-sun.ts', find: ' const e = Math.min(90, Math.max(0, elevation));', replace: ' const e = 45 + 0 * elevation;', }], }, { id: 'iso-sun-streaks-on-dark-floor', guard: 'node demo/smoke_iso_sun.mjs', because: '#649 AC7: the two streaks belong to light floors only', patches: [{ file: 'src/iso-sun.ts', find: ' const streaks = beam.lightFloor ? [inset / length, 1 - inset / length] : [];', replace: ' const streaks = [inset / length, 1 - inset / length];', }], }, { id: 'iso-sun-flat-wedges-remain', guard: 'node demo/smoke_iso_sun.mjs', because: '#649 AC7: in 2.5D the soft wash replaces the projected Flat wedges', patches: [{ file: 'src/houseplan-card.ts', find: " const isoSun = this._renderProjection === 'iso' ? this._isoSceneRuntime : null;", replace: " const isoSun = this._renderProjection === 'iso' && this._editing ? this._isoSceneRuntime : null;", }], }, { id: 'iso-sun-occluders-toward-sun', guard: "npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern=\"AC7 the 2.5D beam is cut\" test/iso-stage6.test.mjs", because: '#649 AC7 (CODE-REVIEW-649-r1 M1): a body shades the floor behind it, away from the sun — ' + 'extruded toward the sun it darkens the lit floor between window and body', patches: [{ file: 'src/iso-sun.ts', find: " const shadows = directionalOccluders(input.occluders, away, travel);", replace: " const shadows = directionalOccluders(input.occluders, toSun, travel);" }], }, { id: 'iso-sun-occluders-ignored', guard: "npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern=\"AC7 the 2.5D beam is cut\" test/iso-stage6.test.mjs", because: '#649 AC7 (CODE-REVIEW-649-r1 M1): the 2.5D beam is cut by the same physical bodies as Flat', patches: [{ file: 'src/iso-sun.ts', find: " if (polys.length && input.occluders?.length) {", replace: " if (polys.length && input.occluders?.length && input.elevation > 90) {" }], }, { id: 'iso-sun-card-drops-occluders', guard: 'node demo/smoke_iso_sun.mjs', because: '#649 AC7 (CODE-REVIEW-649-r1 M1): the card hands the 2.5D beam the same physical bodies ' + 'as the Flat wedges (`_sunInputs`); without them light passes through a partition', patches: [{ file: 'src/houseplan-card.ts', find: "beams: isoSun.computeIsoSunBeams({ ...inputs, azimuth:", replace: "beams: isoSun.computeIsoSunBeams({ ...inputs, occluders: [], azimuth:" }], }, { id: 'iso-theme-dark-wall-rule-returns', guard: 'node demo/smoke_iso_theme_walls.mjs', because: '#649 AC9: the theme never repaints walls — the colour is the user\'s', patches: [{ file: 'src/styles/plan.styles.ts', find: ' .iso-top-lo { stop-color: var(--iso-top-lo, #ededed); }', replace: ' .iso-top-lo { stop-color: var(--iso-top-lo, #ededed); }\n' + ' .stage.theme-dark .iso-top-hi, .stage.theme-dark .iso-side-hi { stop-color: #3a3f44; }', }], }, { id: 'iso-wall-top-constant', guard: 'node demo/smoke_iso_theme_walls.mjs', because: '#649 AC9: the wall top is the user\'s fill_colors.wall_fill, not a constant', patches: [{ file: 'src/iso-materials.ts', find: ' const base = parseHexColor(wallFill) ?? [255, 255, 255];', replace: ' const base = parseHexColor(\'#ffffff\') ?? [255, 255, 255];', }], }, { id: 'volumetric-kiosk-ignores-setting', guard: 'node demo/smoke_volumetric_setting.mjs', because: '#649 AC10: the one installation setting rules the card, the sidebar page and the kiosk', patches: [{ file: 'src/houseplan-card.ts', find: " return this._mode === 'view' && this._isoEnabled ? 'iso' : 'flat';", replace: " return this._mode === 'view' && this._isoEnabled && !this._kiosk ? 'iso' : 'flat';", }], }, { id: 'volumetric-validation-drops-field', guard: 'python3 -m pytest tests_backend/test_validation.py -q -p no:cacheprovider ' + '-k volumetric_view', because: '#649 AC10: the backend accepts only a boolean volumetric_view', patches: [{ file: 'custom_components/houseplan/validation.py', find: ' vol.Optional("volumetric_view"): bool,\n', replace: '', }], }, { id: 'labs-iso-returns', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/labs.test.mjs', because: '#649 AC11: 2.5D left alpha; hp_alpha=1 must not bring it back', patches: [{ file: 'src/labs.ts', find: 'export const LABS_FLAGS: readonly LabsFlag[] = Object.freeze([]);', replace: "export const LABS_FLAGS: readonly LabsFlag[] = Object.freeze([{ id: 'iso', issue: 89, summary: 'Isometric View' }]);", }], }, { id: 'furniture-wall-surface-drops-thickness', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="magnet presses|local atomic|out of reach" ' + 'test/furniture.test.mjs', because: 'falling back to the room centreline recreates #445: BACK enters thick masonry ' + 'and the physical reach threshold moves by half the wall depth', patches: [{ file: 'src/furniture-wall-surface.ts', find: ' const half = Number.isFinite(rawHalf) && rawHalf > 0 ? rawHalf : 0;', replace: ' const half = 0;', }], }, { id: 'furniture-wall-surface-loses-room-side', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="exterior wall exposes|shared thick wall" ' + 'test/furniture.test.mjs', because: 'treating a one-sided room face like an arbitrary body face lets an outside ' + 'pointer flip furniture through an exterior wall and loses shared-room intent (#445)', patches: [{ file: 'src/furniture-placement.ts', find: ' let normal = unit(surface.normal);', replace: ' let normal = unit(null);', }], }, { id: 'furniture-wall-tie-follows-input-order', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="exact-axis placement|corner selection" ' + 'test/furniture.test.mjs', because: 'without the final stable identity comparison an exact shared axis or room corner ' + 'changes side when room/surface arrays are reordered (#445 AC2/AC10)', patches: [{ file: 'src/furniture-placement.ts', find: ' && candidate.stableId.localeCompare(best.stableId) < 0)))) best = candidate;', replace: ' && false)))) best = candidate;', }], }, { id: 'furniture-wall-runtime-drops-raw-intent', guard: 'node demo/smoke_furniture.mjs', because: 'using the grid-snapped axis point for shared-wall side selection loses the user\'s ' + 'actual room-side intent in the editor integration (#445 AC2/AC5)', patches: [{ file: 'src/houseplan-editor-runtime.ts', find: ' intentPoint: [raw[0], raw[1]],', replace: ' intentPoint: [snapped[0], snapped[1]],', }], }, { id: 'furniture-wall-runtime-drops-drag-side', guard: 'node demo/smoke_furniture.mjs', because: 'without the current local +y normal an exact-axis drag may flip furniture to the ' + 'other face of a shared wall (#445 AC2/AC5)', patches: [{ file: 'src/houseplan-editor-runtime.ts', find: ' [rawCx, rawCy], preferredNormal);', replace: ' [rawCx, rawCy]);', }], }, { id: 'furniture-exterior-surface-removed', guard: 'node demo/smoke_furniture.mjs', because: 'the production preview/commit/drag path must keep exterior furniture outside; ' + 'unit-only candidate counts do not prove the shipped interaction (#447 AC3)', patches: [{ file: 'src/furniture-wall-surface.ts', find: ' const exterior = ownersByAtom.get(atomId)?.size === 1 && half > 1e-9;', replace: ' const exterior = false && ownersByAtom.get(atomId)?.size === 1 && half > 1e-9;', }], }, { id: 'decor-keyboard-nudge-reruns-magnet', guard: 'node demo/smoke_decor.mjs', because: 'Arrow fine-tuning must apply an exact delta; even a result-preserving call into ' + 'the decor magnet violates the no-resnap contract and can later move furniture (#447 AC5)', patches: [{ file: 'src/houseplan-card.ts', find: ' const moved = nudgeDecorShape(\n' + ' selected, renderDx, renderDy, NORM_W, this._decorH, CANVAS_LIMIT,\n' + ' );', replace: " this._decorSnap([renderDx, renderDy], 'mouse', selected.id);\n" + ' const moved = nudgeDecorShape(\n' + ' selected, renderDx, renderDy, NORM_W, this._decorH, CANVAS_LIMIT,\n' + ' );', }], }, { id: 'decor-keyboard-nudge-drops-focus-dialog-guards', guard: 'node demo/smoke_decor.mjs', because: 'the global Arrow listener must not steal navigation from fields or mutate decor ' + 'behind an open dialog (#447 AC8)', patches: [{ file: 'src/houseplan-card.ts', find: ' && !inField && !inEditorSecondary && !this._editorSecondaryDialogBlocked) {', replace: ' && !inEditorSecondary) {', }], }, { id: 'plan-device-landmarks-hidden', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/plan-device-landmarks.test.mjs', because: '#687 AC1: the Plan editor shows the same markers as View as landmarks; hiding ' + 'them again removes the reference the owner asked for (browser proof: ' + 'demo/smoke_plan_device_landmarks.mjs; Node witness keeps #659 cap)', patches: [{ file: 'src/styles/plan.styles.ts', find: ' .stage.markup .devlayer .dev {\n' + ' filter: opacity(0.35);\n' + ' }\n', replace: ' .stage.markup .devlayer .dev {\n' + ' display: none;\n' + ' }\n', }], }, { id: 'plan-device-landmarks-override-own-opacity', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/plan-device-landmarks.test.mjs', because: '#687 contract item 2: the fade multiplies the marker\'s own opacity as the ' + 'Background layer does; an opacity override shows an unavailable marker at 35% ' + 'instead of 35% x 35%', patches: [{ file: 'src/styles/plan.styles.ts', find: ' .stage.markup .devlayer .dev {\n' + ' filter: opacity(0.35);\n', replace: ' .stage.markup .devlayer .dev {\n' + ' opacity: 0.35;\n', }], }, { id: 'plan-device-landmarks-hit-target', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/plan-device-landmarks.test.mjs', because: '#687 AC2: a visible marker must never own a Plan-editor point; without the ' + 'pointer boundary its 44 px hit area and capsule swallow the Walls tool click and the ' + 'room settings button below', patches: [{ file: 'src/styles/plan.styles.ts', find: ' .stage.markup .devlayer .dev,\n' + ' .stage.markup .devlayer .dev *,\n' + ' .stage.markup .devlayer .dev::before {\n' + ' pointer-events: none;\n' + ' }\n', replace: '', }], }, { id: 'opening-light-quantum-identity', guard: 'node --test --test-name-pattern="#366" test/logic.test.mjs', because: 'an identity quantum brings back ~100 barrier recomputes per moving-gate cycle — ' + 'the sweep unit must bound distinct signatures to the 0.05 grid (#366)', patches: [{ file: 'src/logic.ts', find: ' return Math.min(1, Math.max(0,\n' + ' Math.round(safe / OPENING_LIGHT_AMOUNT_QUANTUM) * OPENING_LIGHT_AMOUNT_QUANTUM));', replace: ' return safe;', }], }, { id: 'vac-trail-drop-warn-removed', guard: 'node --test --test-name-pattern="#369" test/vacuum.test.mjs', because: 'a NaN calibration must not hide the trail silently — the drop count belongs in the ' + 'console (#369b)', patches: [{ file: 'src/vacuum.ts', find: ' if (droppedSegments > 0) {\n' + ' warn(`[houseplan] vacuum trail: ${droppedSegments} segment(s) dropped — non-finite point (check map calibration)`);\n' + ' }', replace: ' void droppedSegments; void warn;', }], }, { id: 'climate-legacy-area-undefined-lost', guard: 'node --test --test-name-pattern="369в" test/devices.test.mjs', because: 'legacy/import markers carry an absent area key, not an explicit null — strict ' + 'equality quietly demotes their climate back to the registry fallback (#369v)', patches: [{ file: 'src/devices.ts', find: ' if (marker.area == null && marker.space && marker.room_id) {', replace: ' if (marker.area === null && marker.space && marker.room_id) {', }], }, { id: 'opted-out-roster-looks-alive', guard: 'node --test --test-name-pattern="369г" test/device-presentation.test.mjs', because: 'a roster the user deliberately disabled is evidence of opting out, not of life — ' + 'dropping the guard makes the marker glow forever (#369g)', patches: [{ file: 'src/device-presentation.ts', find: ' const activeEntitylessDevice = ownEntities.length === 0\n' + ' && !disabledRoster\n', replace: ' void disabledRoster;\n' + ' const activeEntitylessDevice = ownEntities.length === 0\n', }], }, { id: 'furniture-shift-listeners-not-attached', guard: 'node demo/smoke_furniture_polish.mjs', because: 'without the window listeners the preview ignores Shift until the mouse moves — the ' + 'exact drift the smoke pins (#369d)', patches: [{ file: 'src/houseplan-editor-runtime.ts', find: ' this.host._furnPalette = { symbol, w: d.w, h: d.h };\n' + ' this._furnShiftAttach();', replace: ' this.host._furnPalette = { symbol, w: d.w, h: d.h };', }], }, { id: 'placement-accepts-any-mouse-button', guard: 'node demo/smoke_furniture_polish.mjs', because: 'a right click with an armed tool must not stamp furniture (#369e)', patches: [{ file: 'src/houseplan-editor-runtime.ts', find: " if (ev.pointerType === 'mouse' && ev.button !== 0\n" + " && t !== 'select' && t !== 'erase') return false;", replace: ' // mutant: any button places', }], }, { id: 'backdrop-probe-always-safe', guard: 'node demo/smoke_backdrop_guard.mjs', because: 'a probe that waves every raster through reopens the original hole — a 100 MP scan ' + 'decodes unwarned and kills the tablet tab (#39 AC1)', patches: [{ file: 'src/backdrop-pick.ts', find: " if (probe.kind === 'safe' && file.size <= guardAboveBytes) return { kind: 'pass', ext };", replace: " return { kind: 'pass', ext };", }], }, { id: 'backdrop-downscale-drops-alpha', guard: 'node demo/smoke_backdrop_guard.mjs', because: 'a transparent PNG reduced into JPEG silently paints the plan background black — ' + 'alpha must survive the reduced copy (#39 AC2)', patches: [{ file: 'src/backdrop-pick.ts', find: " const alpha = state.probe.alpha;", replace: ' const alpha = false;', }], }, { id: 'backdrop-hard-demoted-to-warn', guard: 'node demo/smoke_backdrop_guard.mjs', because: 'beyond the 16384 px canvas cap the reduced copy CANNOT be built — offering it is a ' + 'lie that ends in a decode failure (#39 AC4 phase 1)', patches: [{ file: 'src/backdrop-probe.ts', find: " const kind: BackdropVerdict = Math.max(width, height) > HARD_DIMENSION\n" + " ? 'hard'\n" + " : decodedBytes > WARN_DECODED_BYTES ? 'warn' : 'safe';", replace: " const kind: BackdropVerdict = " + "decodedBytes > WARN_DECODED_BYTES ? 'warn' : 'safe';", }], }, { id: 'backdrop-phase2-falls-back-to-original', guard: 'node demo/smoke_backdrop_guard.mjs', because: 'silently uploading the original the user just declined is the exact dishonesty the ' + 'phase-2 contract forbids — staging must stay clean after a failed reduce (#39 AC4b)', patches: [{ file: 'src/backdrop-pick.ts', find: ' } catch {\n' + ' // Honest phase 2 (spec §UX): no silent fallback to the original the\n' + ' // user just declined — staging stays clean, the toast says what happened.\n' + ' if (!stillCurrent()) return;\n' + ' close();\n' + " host._showToast(host._t('backdrop.downscale_failed'));\n" + ' }', replace: ' } catch {\n' + ' const payload = await encodePlanFile(guard.file, guard.ext, guard.file.name);\n' + ' apply(payload);\n' + ' close();\n' + ' }', }], }, { id: 'backdrop-busy-dismiss-races-decision', guard: 'node demo/smoke_backdrop_guard.mjs', because: 'Escape/scrim during the running reduce must not race the decision — the dialog ' + 'stays up while busy, and a force-cleared guard never applies a stale result (#39 r1-M1)', patches: [{ file: 'src/backdrop-pick.ts', find: ' const dismiss = (): void => {\n' + ' if (host._backdropGuard?.busy) return;\n' + ' close();\n' + ' };', replace: ' const dismiss = (): void => {\n' + ' close();\n' + ' };', }], }, { id: 'plan-upload-client-limit', guard: 'node demo/smoke_plan_upload_limit.mjs', because: 'an SVG above the plan limit must be refused at pick time with a toast naming the ' + 'limit — otherwise it is staged and only fails after Save, or, on an old backend, closes ' + 'the socket (#617 AC2)', patches: [{ file: 'src/backdrop-pick.ts', find: " if (file.size > MAX_PLAN_BYTES) return refuse('toast.plan_too_large', { mb: MAX_PLAN_MB });\n", replace: ' // mutant: no client-side plan limit\n', }], }, { id: 'plan-upload-guard-original', guard: 'node demo/smoke_plan_upload_limit.mjs', because: 'above the plan limit the original cannot be stored — offering «Keep the original» ' + 'sends a file the server must refuse; only the reduced copy may be offered (#617 AC3)', patches: [{ file: 'src/backdrop-pick.ts', find: ' return renderBackdropGuard(host, apply, close, hass, undefined, size <= MAX_PLAN_BYTES, ' + 'MAX_PLAN_BYTES);', replace: ' return renderBackdropGuard(host, apply, close, hass, undefined, true, MAX_PLAN_BYTES);', }], }, { id: 'plan-upload-reduced-over-limit-staged', guard: 'node demo/smoke_plan_upload_limit.mjs', because: 'a reduced copy of a huge scan can still exceed the plan limit; staging it defers ' + 'the refusal to a 413 after Save instead of saying so at once (#617 AC3)', patches: [{ file: 'src/backdrop-pick.ts', find: ' if (planLimitBytes !== undefined && out.blob.size > planLimitBytes) {', replace: ' if (planLimitBytes !== undefined && out.blob.size < 0) { // mutant: never over', }], }, { id: 'plan-upload-413-text-dropped', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/plan-upload-limit.test.mjs', because: 'a 413 must read as «file larger than 8 MB», not «too_large» or «HTTP 413»; the ' + 'user has to learn the limit from the error (#617 AC5)', patches: [{ file: 'src/backdrop-pick.ts', find: " too_large: t('err.too_large', { mb: json.max_mb || MAX_PLAN_MB }),\n", replace: '', }], }, { id: 'plan-upload-server-bound', guard: 'python3 -m pytest tests_backend/test_plan_upload.py -q -p no:cacheprovider', because: 'the streaming bound is inclusive: MAX_PLAN_BYTES passes and one byte more is refused ' + 'before the body is buffered whole; a bound that slips by a chunk stores an oversized ' + 'plan (#617 AC4)', patches: [{ file: 'custom_components/houseplan/plans.py', find: ' if size > limit:\n return None\n', replace: ' if size > limit + chunk:\n return None\n', }], }, { id: 'cold-view-vacuum-mapid-delegated', guard: 'node demo/smoke_cold_view_vacuum.mjs', because: 'map-id resolution runs inside willUpdate for every vacuum with telemetry — the ' + '#337 stub threw there on a cold tab and froze the whole Lit update cycle (#358); the ' + 'demo mower has no position attributes, so only this telemetry-bearing cold smoke sees it', patches: [{ file: 'src/houseplan-card.ts', find: ' const ve = this._vacEntity(d);\n' + ' const sel = ve ? planHass?.states?.[ve]?.attributes?.selected_map : null;\n' + ' return vacMapIdWithFallback(tele.mapId, sel);', replace: " return this._editorRuntimeOrThrow()._vacMapId(d, tele, planHass);", }], }, { id: 'cold-view-toggle-delegated-to-runtime', guard: 'node demo/smoke_cold_view_toggle.mjs', because: 'the View card must resolve a tap without the lazy editor runtime — the #337 stub ' + 'threw inside the click handler on every cold tab and a wall switch controlling three ' + 'virtual lamps did nothing until an editor surface was opened (#357)', patches: [{ file: 'src/houseplan-card.ts', find: ' public _toggleIntent(\n' + ' device: DevItem,\n' + ' devices: readonly DevItem[] = this._devices,\n' + ' ): ResolvedToggleIntent | null {\n' + ' return resolveToggleIntent({\n' + ' hass: this._planHass,\n' + ' registryHass: this._fullRegistryHass,\n' + ' devices,\n' + ' device,\n' + ' virtualLights: this._virtualLights,\n' + ' });\n' + ' }', replace: ' public _toggleIntent(\n' + ' device: DevItem,\n' + ' devices: readonly DevItem[] = this._devices,\n' + ' ): ResolvedToggleIntent | null {\n' + " return this._editorRuntimeOrThrow()._toggleIntent(device, devices);\n" + ' }', }], }, { id: 'lazy-loader-network-failure-terminal', guard: 'node --test --test-name-pattern="#353 AC1" test/editor-runtime-loader.test.mjs', because: 'a transient network failure must re-arm the loader for the next explicit press — ' + 'a terminal state blocks the editor until a page refresh (#353 К1)', patches: [{ file: 'src/editor-runtime-loader.ts', find: " this._setState(sawMismatch ? 'failed' : 'idle');", replace: " this._setState('failed');", }], }, { id: 'lazy-loader-terminality-hardcoded', guard: 'node --test --test-name-pattern="#353" test/editor-runtime-loader.test.mjs', because: 'a hardcoded terminal flag shows the wrong advice — the network toast must invite ' + 'another press, the mismatch toast must demand a refresh (#353 К2/AC5)', patches: [{ file: 'src/editor-runtime-loader.ts', find: ' this.options.failed?.(lastError, { terminal: sawMismatch });', replace: ' this.options.failed?.(lastError, { terminal: true });', }], }, { id: 'lazy-chunk-cache-control-reverted', guard: 'node scripts/backend-test-guard.mjs hashed_chunks_are_immutably_cacheable ' + 'tests_backend/test_frontend_assets.py', because: 'content-hashed chunk bodies never change under their URL — reverting to no-cache ' + 'reopens the stale-proxy window and refetches on every editor entry (#353 К4)', patches: [{ file: 'custom_components/houseplan/frontend_asset_manifest.py', find: 'ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"', replace: 'ASSET_CACHE_CONTROL = "no-cache"', }], }, { id: 'bundle-tree-orphans-ignored', guard: 'node --test --test-name-pattern="#353 AC4" test/bundle-assets.test.mjs', because: 'an unlisted chunk on disk rides into the release zip and masks sync bugs — ' + 'the tree check must fail loudly on orphans (#353 К5)', patches: [{ file: 'scripts/bundle-tree.mjs', find: " if (name.endsWith('.js') && !listed.has(`houseplan-assets/${name}`)) {\n" + ' throw new Error(`orphan bundle asset: houseplan-assets/${name}`);\n' + ' }', replace: " void name; void listed;", }], }, { id: 'entry-fallback-rewrite-skipped', guard: 'node --test --test-name-pattern="#486 both stable entries" ' + 'test/bundle-assets.test.mjs', because: 'without the rewrite the entry keeps a STATIC re-export: after an update a cached ' + 'entry aborts before any code runs and the card dies silently (#353 К3)', patches: [{ file: 'scripts/bundle-manifest.mjs', find: ' cardEntry.code = cardEntry.code.replace(\n' + ' cardPattern,\n' + ' `try{await import("${cardAsset}")}`\n' + ' + `catch(e){${fallbackDefinition(cardContract)}`\n' + " + 'console.error(\"[houseplan] stale houseplan-card.js: the implementation chunk is unavailable\",e)}',\n" + ' );', replace: ' void cardEntry; // mutant: cached card facade keeps its static re-export', }], }, { id: 'panel-entry-fallback-rewrite-skipped', guard: 'node --test --test-name-pattern="#486 both stable entries" ' + 'test/bundle-assets.test.mjs', because: 'without its rewrite a stale panel entry aborts on the shared implementation ' + 'before either a card or panel fallback can render (#486 AC4)', patches: [{ file: 'scripts/bundle-manifest.mjs', find: ' panelEntry.code = panelEntry.code.replace(\n' + ' panelPattern,\n' + ' `try{await import("${cardAsset}")}`\n' + ' + `catch(e){${fallbackDefinition(panelContract)}`\n' + " + 'console.error(\"[houseplan] stale houseplan-panel.js: the card implementation '\n" + " + 'chunk is unavailable\",e)}',\n" + ' );', replace: ' void panelEntry; // mutant: cached panel keeps its static shared-chunk import', }], }, { id: 'manifest-loader-judges-current-topology', guard: 'node --test --test-name-pattern="#537 loader-side validation" ' + 'test/bundle-assets.test.mjs', because: 'the loader-side validator also reads FOREIGN manifests — the performance harness ' + 'runs the candidate against a baseline checkout — so a rule about how the CURRENT build ' + 'is wired makes the candidate refuse to load any older baseline. That is what turned all ' + 'nine performance profiles red at once and withheld an asset from a published stable ' + 'release (#537)', patches: [{ file: 'scripts/bundle-tree.mjs', find: ' if (!initialPanel.includes(PANEL_ENTRY)) {\n' + ' throw new Error(`${label}: initial panel graph must contain its own stable entry`);\n' + ' }', replace: ' if (initialPanel.includes(CARD_ENTRY) || !initialPanel.includes(PANEL_ENTRY)) {\n' + ' throw new Error(`${label}: initial panel graph must contain its own stable entry`);\n' + ' }', }], }, { id: 'panel-imports-unversioned-card-entry', guard: 'node --test --test-name-pattern="#486 both stable entries" ' + 'test/bundle-assets.test.mjs', because: 'the stable card facade is the one address in the distribution without a version ' + 'in it, and entries are served without Cache-Control: routing the panel through it let a ' + 'browser keep a previous card for hours and run it against the current backend in silence ' + '(#535 AC1/AC2)', patches: [{ file: 'scripts/bundle-manifest.mjs', find: ' `try{await import("${cardAsset}")}`\n' + ' + `catch(e){${fallbackDefinition(panelContract)}`\n' + " + 'console.error(\"[houseplan] stale houseplan-panel.js: the card implementation '", replace: ' `try{await import("./${CARD_ENTRY_FILE}")}`\n' + ' + `catch(e){${fallbackDefinition(panelContract)}`\n' + " + 'console.error(\"[houseplan] stale houseplan-panel.js: the card implementation '", }], }, { id: 'panel-entry-bypasses-card-graph', guard: 'node --test --test-name-pattern="#486 both stable entries" ' + 'test/bundle-assets.test.mjs', because: 'the manifest edge must name what the panel actually imports. Since #535 that is ' + 'the hashed implementation, not the stable facade; an edge naming the facade would make ' + 'initialPanelFiles describe a graph the panel never loads, and the "panel reuses the ' + 'exact card graph" check would then pass on a fiction (#486 AC5, #535 AC4)', patches: [{ file: 'scripts/bundle-manifest.mjs', find: " panelEntry.imports = [cardAsset.replace(/^\\.\\//, '')];", replace: ' panelEntry.imports = [CARD_ENTRY_FILE]; // mutant: edge names the facade', }], }, { id: 'junction-limit-key-precision-lost', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="#331 AC1" test/junction-limits.test.mjs', because: 'toFixed(6) keys split one node into two on floating debris and forked on the ' + 'sign of zero — two false П4 refusals on a legitimate resize (#331 §2.1)', patches: [{ file: 'src/junction-limits.ts', find: 'const key = (point: number[]): string =>\n' + ' `${quantizeKeyCoord(point[0])},${quantizeKeyCoord(point[1])}`;', replace: 'const key = (point: number[]): string => ' + '`${point[0].toFixed(6)},${point[1].toFixed(6)}`;', }], }, { id: 'junction-limit-ts-python-parity-drift', guard: 'npx tsc -p tsconfig.junction-parity.json && node scripts/fix-test-build.mjs ' + '&& python3 tests_backend/junction_parity.py --build-dir=test-build/junction-parity', because: 'the clean-runner parity job must turn red when either production mirror changes ' + 'one shared rule; otherwise a green run can again mean that no cross-runtime comparison ' + 'actually happened (#548 AC2)', patches: [{ file: 'src/junction-limits.ts', find: 'export const MIN_NODE_DISTANCE_CM = 5;', replace: 'export const MIN_NODE_DISTANCE_CM = 4; // mutant: drift from Python mirror', }], }, { id: 'junction-limit-branch-dropped', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="#331 AC3" test/junction-limits.test.mjs', because: 'taking only the first collinear continuation silently drops every fork and ' + 'understates the wall run — a legitimate thickness-step filler gets refused (#331 §2.3)', patches: [{ file: 'src/junction-limits.ts', find: ' for (const candidate of byNode.get(key(node)) || []) {', replace: ' for (const candidate of (byNode.get(key(node)) || []).slice(0, 1)) {', }], }, { id: 'junction-limit-candidate-fail-open', guard: 'node demo/smoke_junction_limits.mjs', because: 'an exception while judging the candidate must refuse the write — a silent pass ' + 'reopens the fail-open hole the #278 guard closed for geometry (#331 §2.5)', patches: [{ file: 'src/houseplan-card.ts', find: " return [{ rule: 'check_failed', subject: spaceId, actual: 0, limit: 0 }];", replace: ' return [];', }], }, { id: 'junction-limit-p3-quadratic-again', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node demo/benchmark_junction_limits.mjs', because: 'П3 rebuilding its node index per segment is the exact O(n²) #330 removed ' + '(289 ms per call on 576 atoms, twice per pointermove) — the benchmark budget ' + 'must turn red the day it returns', patches: [{ file: 'src/junction-limits.ts', find: ' const units = collinearRunLengthUnits(segment, usable, byNode);', replace: ' const units = collinearRunLengthUnits(segment, usable);', }], }, { id: 'junction-limit-p4-bruteforce-again', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node demo/benchmark_junction_limits.mjs', because: 'П4 is architecturally quadratic without the bucket grid (#330 §4.5); feeding ' + 'every node the whole segment list instead of its cell brings the 104-372 ms cost ' + 'back and the benchmark must catch it', patches: [{ file: 'src/junction-limits.ts', find: ' for (const segment of segmentGrid.get(`${cx},${cy}`) || []) {', replace: ' for (const segment of usable) {', }], }, { id: 'junction-limit-baseline-cache-stale', // r2-M1: the behavioural oracle counts BASELINE computations (exactly one // per gesture with a working cache, one per pointermove without). Keep it // as the sole oracle: a second behavioural command in an `&&` prefix is a // setup phase under the honest #550 taxonomy and cannot prove the mutant. guard: 'node demo/smoke_junction_limits.mjs', because: 'a baseline cache that survives a config epoch change serves verdicts of a ' + 'plan that no longer exists — the epoch check IS the invalidation contract (#330 §4.4)', patches: [{ file: 'src/houseplan-card.ts', find: " if (cached && fingerprint && cached.fingerprint === fingerprint\n" + ' && cached.spaceId === spaceId) {', replace: ' if (cached && cached.spaceId === spaceId) {', }], }, { id: 'junction-limit-optimize-unguarded', guard: 'node scripts/backend-test-guard.mjs ' + 'test_333_optimize_refuses_a_crafted_violation ' + 'tests_backend/test_ha_websocket.py', because: 'plan/optimize writes arbitrary client geometry; without the junction gate a ' + 'crafted payload persists violations that inheritance then legalises for every later ' + 'config/set (#333 AC1)', patches: [{ file: 'custom_components/houseplan/websocket_api.py', find: ' return validate_junction_limits(\n' + ' msg["config"], config_data.get("config"),\n' + ' ), None', replace: ' return {}, None', }], }, { id: 'junction-limit-backend-raw-baseline', guard: 'node scripts/backend-test-guard.mjs ' + 'test_legacy_baseline_is_judged_after_the_same_migration ' + 'tests_backend/test_junction_limits.py', because: 'a legacy baseline carries no wall catalogue, so judging it raw reports "no ' + 'violations" whatever its geometry and turns every inherited one into a refusal of ' + 'an unrelated edit (#329 §3, code review r1 H1)', patches: [{ file: 'custom_components/houseplan/junction_limits.py', find: ' migrated, _ = commit_wall_segment_model(config)', replace: ' migrated = config # mutant: judge the raw document', }], }, { id: 'junction-limit-angle-not-enforced', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="П1" test/junction-limits.test.mjs', because: 'the owner-approved 15° minimum between neighbouring walls is a hard limit; ' + 'lowering it silently lets the trident apex of #329 be drawn again', patches: [{ file: 'src/junction-limits.ts', find: 'export const MIN_JUNCTION_ANGLE_DEG = 15;', replace: 'export const MIN_JUNCTION_ANGLE_DEG = 0;', }], }, { id: 'junction-limit-write-gate-removed', guard: 'node demo/smoke_junction_limits.mjs', because: 'the limits must refuse the WRITE, not merely report it — without the barrier ' + 'an impossible junction reaches the saved document (#329 §2)', patches: [{ file: 'src/houseplan-card.ts', find: ' if (introduced.length) {\n this._clearGeometryGesture();', replace: ' if (false && introduced.length) {\n this._clearGeometryGesture();', }], }, { id: 'wall-draw-full-preflight-again', guard: 'node demo/benchmark_wall_draw_click.mjs', because: 'every intermediate Walls click must use the bounded #461 barrier; routing it back ' + 'through the generic full-space transaction recreates the measured one-second pause', patches: [{ file: 'src/houseplan-editor-runtime.ts', find: " if (!commitWallChainSegmentGeometry(this, this.host._t('history.wall_segment'), before)) {", replace: " if (!this._commitPhysicalGeometry(this.host._t('history.wall_segment'), before)) {", }], }, { id: 'room-accept-leaves-coincident-partitions', guard: 'node demo/smoke_unified_wall_tool.mjs', because: 'accepting a face must consume active and older coincident partition intervals in ' + 'the same transaction; otherwise the next Optimize repairs the just-created room (#478 AC13)', patches: [{ file: 'src/houseplan-editor-runtime.ts', find: ' allowCoincidentPartitions: true, // #478 room acceptance consumes its wall carriers.', replace: ' allowCoincidentPartitions: false, // mutant: accepted carriers survive', }], }, { id: 'wall-draw-local-neighbour-dropped', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="local projection retains" ' + 'test/draft-live-preflight.test.mjs', because: 'a bounded proof that drops a room whose masonry envelope meets the new segment ' + 'can accept geometry the generic barrier rejects (#461 AC4)', patches: [{ file: 'src/draft-live-preflight.ts', find: ' return id && near ? [id] : [];', // Пустой литерал выводится как `never[]`, и вызывающие падают на строках; // тип сохраняется, сосед теряется — это мутант и заявляет (#569). replace: ' return [] as string[];', }], }, { id: 'wall-draw-rejection-rollback-skipped', guard: 'node demo/smoke_wall_draw_click.mjs', because: 'a junction-rejected terminal click must restore its config snapshot before the ' + 'gesture is cleared, otherwise the invalid point survives without history or a write (#461 AC6)', patches: [{ file: 'src/draft-live-commit.ts', find: ' if (introduced.length) {\n' + ' runtime._clearGeometryGesture();\n' + ' runtime._restoreGeometryStateLocal(before);\n' + ' host._showToast(runtime._junctionLimitLabel(introduced[0]));\n' + ' return false;\n' + ' }\n\n' + ' adoptWallSegmentModelCandidateInPlace(liveCandidate, committedCandidate);\n' + ' const acceptedSpace = liveCandidate.spaces.find((space) => space.id === before.spaceId);\n' + ' const accepted = acceptedSpace?.partitions?.find((partition) => partition.id === partitionId);', replace: ' if (introduced.length) {\n' + ' runtime._clearGeometryGesture();\n' + ' host._showToast(runtime._junctionLimitLabel(introduced[0]));\n' + ' return false; // mutant: rejected segment survives\n' + ' }\n\n' + ' adoptWallSegmentModelCandidateInPlace(liveCandidate, committedCandidate);\n' + ' const acceptedSpace = liveCandidate.spaces.find((space) => space.id === before.spaceId);\n' + ' const accepted = acceptedSpace?.partitions?.find((partition) => partition.id === partitionId);', }], }, { id: 'wall-draw-wall-artifact-discarded', guard: 'node demo/benchmark_wall_draw_click.mjs', because: 'the exact candidate wall union already computed by physical preflight must feed ' + 'the junction pass; discarding it repays the expensive union on every click (#461 AC5)', patches: [{ file: 'src/draft-live-commit.ts', find: ' localCandidate, localPrevious, before.spaceId, geometry, candidateProjection.roomIds,\n' + ' );\n' + ' if (introduced.length) {\n' + ' runtime._clearGeometryGesture();\n' + ' runtime._restoreGeometryStateLocal(before);\n' + ' host._showToast(runtime._junctionLimitLabel(introduced[0]));\n' + ' return false;\n' + ' }\n\n' + ' adoptWallSegmentModelCandidateInPlace(liveCandidate, committedCandidate);\n' + ' const acceptedSpace = liveCandidate.spaces.find((space) => space.id === before.spaceId);\n' + ' const accepted = acceptedSpace?.partitions?.find((partition) => partition.id === partitionId);', replace: ' localCandidate, localPrevious, before.spaceId, null, candidateProjection.roomIds,\n' + ' );\n' + ' if (introduced.length) {\n' + ' runtime._clearGeometryGesture();\n' + ' runtime._restoreGeometryStateLocal(before);\n' + ' host._showToast(runtime._junctionLimitLabel(introduced[0]));\n' + ' return false;\n' + ' }\n\n' + ' adoptWallSegmentModelCandidateInPlace(liveCandidate, committedCandidate);\n' + ' const acceptedSpace = liveCandidate.spaces.find((space) => space.id === before.spaceId);\n' + ' const accepted = acceptedSpace?.partitions?.find((partition) => partition.id === partitionId);', }], }, { id: 'degenerate-apex-bevelled-again', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="§4" test/junction-limits.test.mjs', because: 'a sharp apex must end in ONE point on both faces; the two-point bevel is the ' + 'flat cut the owner rejected and the bow-tie fold that carved the jags (#329 §4)', patches: [{ file: 'src/wall-thickness.ts', find: ' if (isDegenerateApexCorner(poly, offsets, i)) {\n' + ' out.push([poly[i][0], poly[i][1]]);\n' + ' continue;\n }', replace: ' // mutant: degenerate apex falls back to the bevel', }], }, { id: 'resize-preview-reject-silent', guard: 'node demo/smoke_room_resize.mjs', because: 'an unexpected runtime preflight rejection must explain why an enabled handle ' + 'stopped instead of restoring the original silent no-op (#293)', patches: [{ file: 'src/houseplan-card.ts', find: [ ' this._showToast(this._rszLimitViolation', ' ? `${this._t(\'resize.limit_stopped\')} — `', ' + this._junctionLimitLabel(this._rszLimitViolation)', " : this._t('resize.preview_failed'));", ].join('\n'), replace: ' // mutant: reject remains silent', }], }, { id: 'resize-labels-show-centreline', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="innerEdgeSpan measures between wall faces" ' + 'test/wall-thickness.test.mjs', because: 'resize labels must report the distance a person measures with a tape between wall ' + 'faces; centreline numbers cannot be checked against anything and sat next to an area ' + 'that was already computed from the floor (#233)', patches: [{ file: 'src/wall-thickness.ts', find: ' const span = end - start;', replace: ' const span = centre;', }], }, { id: 'resize-labels-drops-measured-edge', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/resize-labels.test.mjs', because: 'each of the two remaining length badges must retain a matching side-wall ' + 'highlight; duplicating one edge silently drops the other measurement (#300)', patches: [{ file: 'src/resize-labels.ts', find: ' return [(movingEdge - 1 + n) % n, (movingEdge + 1) % n];', replace: ' return [(movingEdge - 1 + n) % n, (movingEdge - 1 + n) % n];', }], }, { id: 'resize-labels-same-side-areas', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/resize-labels.test.mjs', because: 'the inward side must follow polygon winding; a shared wall needs its two room ' + 'areas on opposite sides instead of stacked together (#300)', patches: [{ file: 'src/resize-labels.ts', find: ' return signedArea(poly) >= 0\n', replace: ' return true\n', }], }, { id: 'resize-labels-ignore-gear-collision', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/resize-labels.test.mjs', because: 'a visible room-settings button must move the area badge tangentially at current ' + 'zoom; keeping the nominal position recreates the overlap (#300)', patches: [{ file: 'src/resize-labels.ts', find: ' if (collides(0)) {', replace: ' if (false && collides(0)) {', }], }, { id: 'resize-labels-hide-narrow-area', guard: 'node demo/smoke_resize_labels.mjs', because: 'the owner chose always-visible area labels; keeping only one owner in a narrow ' + 'shared fixture loses the second room instead of using its leader (#300)', patches: [{ file: 'src/houseplan-card.ts', find: ' for (const id of ids) {\n const poly = res.polys[id]', replace: ' for (const id of ids.slice(0, 1)) {\n const poly = res.polys[id]', }], }, { id: 'resize-wall-partial-overlap-not-split', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 253 splits a longer exact wall" ' + 'test/wall-thickness.test.mjs', because: 'a room edge may cover only part of a longer thickness record; omitting the overlap ' + 'boundaries recreates #253 by leaving the entire record behind instead of moving one atom', patches: [{ file: 'src/wall-thickness.ts', find: ' const bounds = [0, 1, ...overlaps.flatMap(({ lo, hi }) => [lo, hi])]', replace: ' const bounds = [0, 1]', }], }, { id: 'resize-wall-key-collision-drops-record', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="key collisions never erase" ' + 'test/wall-thickness.test.mjs', because: 'compatibility keys identify a quantised midpoint, not an exact interval; treating ' + 'them as unique silently deletes different thickness records, the second half of #253', patches: [{ file: 'src/wall-thickness.ts', find: ' const duplicate = exactOut.some((candidate) => candidate.entry.cm === value\n' + ' && closePoint(candidate.span[0], ca) && closePoint(candidate.span[1], cb));', replace: ' const duplicate = exactOut.some((candidate) =>\n' + ' candidate.entry.key === keyOf(ca, cb, pitch, scale));', }], }, { id: 'wall-key-storage-normalization-disabled', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 258 wallKey" ' + 'test/wall-thickness.test.mjs', because: 'one grid endpoint has exact and nine-decimal storage forms; keying their midpoint ' + 'without first stabilising the endpoints recreates the one-grid-step key fork from #258', patches: [{ file: 'src/wall-thickness.ts', find: ' return Math.abs(snapped - v) <= keyEpsilon(pitch) ? snapped : v;', replace: ' return v;', }], }, { id: 'multi-wall-orthogonal-strip-protection-disabled', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue #271 keeps finite" ' + 'test/wall-thickness.test.mjs', because: 'a short-support trim (#271) must still exclude and restore every finite strip ' + 'protected by an orthogonal partner (#275); disabling both protections cuts visible ' + 'notches from the reachable combined fixture', patches: [{ file: 'src/wall-thickness.ts', find: ' return cuts && protectedStrips ? difference(cuts, protectedStrips) : cuts;', replace: ' return cuts;', }, { file: 'src/wall-thickness.ts', find: ' if (protectedStrips) local = union(local, protectedStrips);', replace: ' // protected-strip fail-safe intentionally disabled by the mutant', }, { file: 'src/wall-thickness.ts', find: ' if (protectedStrips) {\n' + ' try {\n' + ' let protectedInside = protectedStrips;', replace: ' if (protectedStrips && false) {\n' + ' try {\n' + ' let protectedInside = protectedStrips;', }], }, { id: 'multi-wall-finite-ray-disabled', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue #271 keeps finite" ' + 'test/wall-thickness.test.mjs', because: 'a degree-3 repair must stop at every real interval endpoint; restoring the ' + 'node-wide 8H rectangle recreates the phantom wall and light barrier from #271', patches: [{ file: 'src/wall-thickness.ts', find: ' const supportExtent = Math.min(extent, support.length);', replace: ' const supportExtent = extent;', }], }, { id: 'multi-wall-shared-continuation-protection-disabled', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue #288 keeps" ' + 'test/wall-thickness.test.mjs', because: 'a node-wide replacement mask must retain the finite shared wall attached to the ' + 'far endpoint of a short ray; dropping that ownership recreates the four 45-step gaps ' + 'from the real second-floor fixture (#288)', patches: [{ file: 'src/wall-thickness.ts', find: " if (candidate.kind !== 'shared'\n", replace: " if (candidate.kind === 'shared'\n", }], }, { id: 'multi-wall-exterior-corridor-disabled', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue #272 keeps a short non-orthogonal trim" ' + 'test/wall-thickness.test.mjs', because: 'a reachable short-support bevel cut must cross the exterior with finite width; ' + 'ending it at one mathematical point recreates the enclosed white triangle from #272', patches: [{ file: 'src/wall-thickness.ts', find: ' multiWallBevelCutsAt(nodeMap, retainToLimit, connectToExterior),', replace: ' multiWallBevelCutsAt(nodeMap, retainToLimit, false),', }], }, { id: 'wall-exact-span-fallback-disabled', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 258 exact-span" ' + 'test/wall-thickness.test.mjs', because: 'already affected plans must resolve the same physical a/b span immediately even ' + 'when their persisted midpoint key is on the other side of the rounding tie (#258)', patches: [{ file: 'src/wall-thickness.ts', find: ' const exactEps = keyEpsilon(pitch) * scale;', replace: ' const exactEps = -1;', }], }, { id: 'invariant-wall-key-storage-normalization-disabled', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="копия wallKey" ' + 'test/model-invariants.test.mjs', because: 'the raw-model invariant keeps a deliberate copy of production wallKey; if its ' + 'storage-noise normalisation drifts, the diagnostic and runtime disagree again (#258)', patches: [{ file: 'scripts/model-invariants.mjs', find: ' return Math.abs(snapped - value) <= keyEpsilon(pitch) ? snapped : value;', replace: ' return value;', }], }, { id: 'unit-formatting-escapes-the-formatter', guard: 'node --test --test-name-pattern="детектор действительно ловит" ' + 'test/single-source-numbers.test.mjs', because: 'одно число, видимое дважды, обязано иметь один источник: расхождение превью и ' + 'записи стоило продукту #234, а расхождение подписи и площади — #233. Детектор без ' + 'способности сработать — это разрешение собирать подписи где угодно', patches: [{ file: 'test/single-source-numbers.test.mjs', find: " + '\\\\s*(m²|ft²|м²|m\\\\b|cm\\\\b|см(?![а-яё])|м(?![а-яё])|′|″)',", replace: " + '\\\\s*(ЗАГЛУШКА)',", }], }, { id: 'invariant-loses-wall-record', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="исчезнувшая запись толщины" ' + 'test/model-invariants.test.mjs', because: 'потеря записи толщины — это дефект #253, найденный человеком глазами; если ' + 'инвариант перестанет её замечать, класс вернётся в продукт незамеченным (#254)', patches: [{ file: 'src/wall-record-preservation.ts', find: ' if (now === 0) {', replace: ' if (false) {', }], }, { id: 'budget-warning-never-fires', guard: 'node --test --test-name-pattern="срабатывает до стены" test/bundle-assets.test.mjs', because: 'прежняя редакция полагалась на то, что человек заметит тренд в выводе: за сутки ' + 'запас ушёл с 26 КБ до 8.3 КБ, и не заметил никто — каждая отдельная строка выглядела ' + 'нормально (#367)', patches: [{ file: 'scripts/bundle-budget.mjs', find: ' if (!Number.isFinite(headroom) || headroom >= threshold) return null;', replace: ' if (true) return null;', }], }, { id: 'mutation-report-drops-missing-shard', guard: 'node --test --test-name-pattern="отсутствующий лог шарда" test/mutation-gate-report.test.mjs', because: 'a shard whose artifact never arrived is an unknown, not a clean shard — ' + 'treating it as ok would let a whole quarter of the registry escape silently (#472)', patches: [{ file: 'scripts/mutation-gate-report.mjs', find: " if (text == null) { shards.push({ shard, status: 'missing' }); continue; }", replace: " if (text == null) { shards.push({ shard, status: 'ok' }); continue; }", }], }, { id: 'mutation-report-duplicates-escaped', guard: 'node --test --test-name-pattern="без дублей и по порядку" test/mutation-gate-report.test.mjs', because: 'the same mutant reported by two shards must be one line with one command, ' + 'otherwise the report double-counts and the owner chases a phantom (#472)', patches: [{ file: 'scripts/mutation-gate-report.mjs', find: " escaped: [...escaped].sort(),", replace: " escaped: logs.flatMap(() => [...escaped]).sort(),", }], }, { id: 'mutation-report-red-guard-as-mutant', guard: 'node --test --test-name-pattern="красный гард без мутанта" test/mutation-gate-report.test.mjs', because: 'runCleanGuards prints `FAIL чистый прогон: …` into the same log; a parser that ' + 'takes the word after FAIL invents a mutant named «чистый» whose --id command does ' + 'not exist (#472, spec review r1)', patches: [{ file: 'scripts/mutation-gate-report.mjs', find: " if (asEscaped && known.has(asEscaped[1])) { escaped.add(asEscaped[1]); continue; }", replace: " if (asEscaped) { escaped.add(asEscaped[1]); continue; }\n if (/^FAIL (\\S+)/.test(line)) { escaped.add(line.split(' ')[1].replace(/:$/, '')); continue; }", }], }, { id: 'mutation-report-setup-as-unparsed', guard: 'node --test --test-name-pattern="#550: setup/invalid/interruption" ' + 'test/mutation-gate-report.test.mjs', because: 'setup, invalid-patch and infrastructure outcomes must be called out as missing ' + 'evidence; losing their parser branch buries the reason in generic text and invites a ' + 'false escaped/caught interpretation (#550 AC4)', patches: [{ file: 'scripts/mutation-gate-report.mjs', find: ' if (asUnverifiable && known.has(asUnverifiable[1])) {', replace: ' if (false && asUnverifiable && known.has(asUnverifiable[1])) {', }], }, { id: 'mutation-report-accepts-foreign-material', guard: 'node --test --test-name-pattern="foreign SHA и отсутствующий шард" ' + 'test/mutation-gate-report.test.mjs', because: 'without the material-SHA check, a partial retry can combine a fresh shard with ' + 'three logs from an older dev tree and publish a green result that no commit earned (#549)', patches: [{ file: 'scripts/mutation-gate-report.mjs', find: ' if (evidence.materialSha !== expected.materialSha) errors.push(`shard ${shard}: foreign material SHA`);', replace: ' if (false && evidence.materialSha !== expected.materialSha) errors.push(`shard ${shard}: foreign material SHA`);', }], }, // #604: шард, снятый по таймауту, оставляет лог без строк FAIL и без итоговой // строки; отчёт называл его «ok», а агрегатор — доказанным. { id: 'mutation-report-truncated-log-is-ok', guard: 'node --test --test-name-pattern="#604: лог без итоговой строки" test/mutation-gate-report.test.mjs', because: 'a log cut by timeout-minutes has no FAIL lines only because the run never reached ' + 'them; calling it ok published "| 2 | ok |" for the cancelled shard of 21.09 (#604)', patches: [{ file: 'scripts/mutation-gate-report.mjs', find: " else if (INTERRUPTED_OUTCOMES.has(outcome) || !summary) status = 'interrupted';", replace: " else if (INTERRUPTED_OUTCOMES.has(outcome)) status = 'interrupted'; // mutant: no summary line still ok", }], }, { id: 'mutation-evidence-ignores-cancelled-step', guard: 'node --test --test-name-pattern="#604: evidence несёт исход шага" test/mutation-gate-report.test.mjs', because: 'the evidence job proves completeness, not just identity: a shard whose gate step ' + 'was cancelled by the job timeout must not count towards a proven material (#604)', patches: [{ file: 'scripts/mutation-gate-report.mjs', find: " if (INTERRUPTED_OUTCOMES.has(evidence.outcome)) {\n errors.push(`shard ${shard}: run was interrupted (step outcome ${evidence.outcome})`);\n }", replace: " if (false && INTERRUPTED_OUTCOMES.has(evidence.outcome)) {\n errors.push(`shard ${shard}: run was interrupted (step outcome ${evidence.outcome})`);\n }", }], }, // #620: ночь по неизменённому дереву и окружение шарда по гардам плана. { id: 'nightly-reuse-ignores-tree', guard: 'node --test --test-name-pattern="маркер другого дерева" test/mutation-nightly-reuse.test.mjs', because: 'a green marker proves exactly one tree; accepting it for another tree would skip the ' + 'nightly registry on changed code and hide rotted witnesses until release (#620)', patches: [{ file: 'scripts/mutation-nightly-reuse.mjs', find: " if (marker.tree !== tree) return no('marker proves another tree');", replace: " if (false && marker.tree !== tree) return no('marker proves another tree');", }], }, { id: 'nightly-reuse-accepts-stale-marker', guard: 'node --test --test-name-pattern="старше" test/mutation-nightly-reuse.test.mjs', because: 'the tree pins code, not the runner; without the age limit an unchanged dev would never ' + 'run the registry on a fresh environment again (#620)', patches: [{ file: 'scripts/mutation-nightly-reuse.mjs', find: ' if (now - provenAt > maxAgeDays * DAY_MS) return no(', replace: ' if (now - provenAt > maxAgeDays * DAY_MS * 1000) return no(', }], }, { id: 'nightly-reuse-on-manual-dispatch', guard: 'node --test --test-name-pattern="ручной dispatch" test/mutation-nightly-reuse.test.mjs', because: 'manual dispatch is how the gate itself is debugged; reusing a marker there would ' + 'answer a debugging run with an old result (#620)', patches: [{ file: 'scripts/mutation-nightly-reuse.mjs', find: " if (event !== 'schedule') return no(", replace: " if (event === 'mutant-never-an-event') return no(", }], }, { id: 'green-marker-without-green-aggregator', guard: 'node --test --test-name-pattern="#620: пропуск ночи" test/mutation-gate.test.mjs', because: 'a marker written after a red or partial run would let the next night skip the registry ' + 'and never file the failure issue again (#620, #472)', patches: [{ file: '.github/workflows/_mutation-gate.yml', find: " if: needs.material.outputs.reuse != 'true' && needs.mutants.result == 'success' && needs.evidence.result == 'success'", replace: " if: always() && needs.material.outputs.reuse != 'true'", }], }, { id: 'nightly-reuse-decision-error-skips-registry', guard: 'node --test --test-name-pattern="#620: пропуск ночи" test/mutation-gate.test.mjs', because: 'a skipped night must be proved by a marker; a failed decision step has to mean ' + 'a full run, not a silent skip (#620)', patches: [{ file: '.github/workflows/_mutation-gate.yml', find: ' echo "reuse=false" >> "$GITHUB_OUTPUT"', replace: ' echo "reuse=true" >> "$GITHUB_OUTPUT"', }], }, { id: 'browser-shard-skips-chromium', guard: 'node --test --test-name-pattern="#620 AC2" test/validate-workflow.test.mjs', because: 'a shard whose guards open a browser must install Chromium, or its clean run goes red ' + 'and the task loses a review round for nothing (#620)', patches: [{ file: '.github/workflows/validate.yml', find: " if: steps.plan.outputs.count != '0' && steps.plan.outputs.browser == 'true' && steps.pw.outputs.cache-hit != 'true'", replace: " if: steps.plan.outputs.count != '0' && steps.plan.outputs.browser == 'yes' && steps.pw.outputs.cache-hit != 'true'", }], }, { id: 'unread-plan-environment-skips-install', guard: 'node --test --test-name-pattern="#620 AC2" test/validate-workflow.test.mjs', because: 'a plan line that was not read must mean "install": skipping the environment on an ' + 'unknown need trades minutes for a red shard (#620)', patches: [{ file: '.github/workflows/validate.yml', find: ' echo "browser=${browser:-true}" >> "$GITHUB_OUTPUT"', replace: ' echo "browser=${browser:-false}" >> "$GITHUB_OUTPUT"', }], }, { id: 'environment-misses-playwright-import', guard: 'node --test --test-name-pattern="#620: браузер нужен" test/mutation-gate.test.mjs', because: 'a unit guard that reaches Playwright through an import still needs Chromium; the ' + 'command line alone does not show it (#620)', patches: [{ file: 'scripts/mutation-environment.mjs', find: ' || sources.some((source) => SOURCE_BROWSER_RE.test(source)),', replace: ' || sources.length < 0,', }], }, { id: 'environment-misses-backend-files', guard: 'node --test --test-name-pattern="#620: Python нужен" test/mutation-gate.test.mjs', because: 'a wrapper guard runs pytest on the declared .py files; missing them leaves the shard ' + 'without backend dependencies (#620)', patches: [{ file: 'scripts/mutation-environment.mjs', find: " python: GUARD_PYTHON_RE.test(text) || files.some((file) => file.endsWith('.py'))", replace: " python: GUARD_PYTHON_RE.test(text) || files.some((file) => file.endsWith('.pyc'))", }], }, { id: 'environment-ignores-spawned-scripts', guard: 'node --test --test-name-pattern="#620: Python нужен" test/mutation-gate.test.mjs', because: 'a test that spawns a script runs that script as a process; its needs are the ' + "guard's needs (#620)", patches: [{ file: 'scripts/mutation-environment.mjs', find: ' if (entry) {', replace: ' if (entry && file.length < 0) {', }], }, // #481: журнал пойманных свидетелей — каждый защитный контракт под свидетелем. { id: 'ledger-records-escaped', guard: 'node --test --test-name-pattern="#481 AC3|#481 AC2" test/mutation-gate.test.mjs', because: 'the ledger may only remember a CAUGHT witness; recording an escaped one would ' + 'skip it on every later push and hide the exact rot the gate exists for (#481)', patches: [{ file: 'scripts/mutation-gate.mjs', // Якорь переехал: между `isProofOutcome` и этой строкой встала атрибуция // отказа подготовки (#568), поэтому патч цепляется за сам сторож. find: ' if (outcome.kind !== MUTATION_OUTCOME.SURVIVED) unverifiable = true;', replace: " if (ledger) recordCaught(ledgerArg, ledger, entry.mutant, entry.fingerprint, 'assertion');\n" + ' if (outcome.kind !== MUTATION_OUTCOME.SURVIVED) unverifiable = true;', }, { // Unit-наблюдаемая половина того же контракта: пустой отпечаток в // записи считался бы «совпавшим» с любым — журнал перестаёт сравнивать. file: 'scripts/mutation-evidence.mjs', find: ' if (proof?.fingerprint === fingerprint && proof.proof === expectedProof\n' + ' && validProof(proof.proof)) skipped.push(mutant);', replace: ' if ((proof?.fingerprint === fingerprint && proof.proof === expectedProof\n' + ' && validProof(proof.proof)) || mutant.id in ledger.caught) skipped.push(mutant);', }], }, { id: 'room-drafts-refuse-instead-of-heal', guard: 'node scripts/backend-test-guard.mjs ' + 'room_drafts_on_current_model_convert_exactly_like_the_first_migration ' + 'tests_backend/test_wall_segment_model.py', because: '#529: наследие обязано сниматься миграцией, а не запирать план — отказ здесь ' + 'отбивал структурную правку ещё до отправки на сервер и заодно ломал экспорт, ' + 'то есть единственный способ вытащить бэкап и починить конфиг руками', patches: [{ file: 'custom_components/houseplan/wall_segment_model.py', find: ' if not drafts:\n' + ' space.pop("room_drafts", None)\n' + ' return 0, 0', replace: ' if True:\n' + ' raise WallSegmentMigrationError("duplicate-id", "model v10 must not contain room_drafts")', }], }, { id: 'outdated-client-detected-by-model-number', guard: 'node scripts/backend-test-guard.mjs ' + 'outdated_client_is_recognised_by_the_carrier_not_by_the_model_number ' + 'tests_backend/test_wall_segment_model.py', because: '#529: устаревшая карточка возвращает эхом полученный номер модели, поэтому ' + 'признаком служит сам носитель поверх чистого сохранённого конфига; сверка по ' + 'номеру пропускала ровно тот случай, ради которого сторож и написан (#478 AC2)', patches: [{ file: 'custom_components/houseplan/validation.py', find: ' if old_model >= 10 and not stored_drafts and any(', replace: ' if old_model >= 10 and new_model < 10 and any(', }], }, { id: 'room-drafts-mirror-still-throws', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="#529" test/wall-segment-model.test.mjs', because: '#529: зеркало в карточке лечит хранилище — именно оно снимает ключ в кандидате, ' + 'который уходит в config/set; если оно снова бросает, план остаётся запертым ' + 'независимо от того, что умеет бэкенд', patches: [{ file: 'src/wall-segment-model.ts', find: ' if (!drafts.length) {\n' + ' delete space.room_drafts;\n' + ' return { drafts: 0, segments: 0 };\n' + ' }', // Условие истинно в рантайме, но не статически: с литеральным `true` // остаток функции недостижим, и TypeScript теряет сужения ниже (#569). replace: " if (drafts.length >= 0) throw new WallSegmentModelError('duplicate-id', 'model v10 must not contain room_drafts');", }], }, { id: 'css-minifier-skips-typescript-output', guard: 'node --test --test-name-pattern="#526 AC4|#526 AC2" test/bundle-assets.test.mjs', because: '#526: плагин видит вывод TypeScript, где тег отделён от шаблона пробелом; ' + 'строгий отсев `css` вплотную к бэктику молча выключает минификацию целиком — ' + 'так 23 КБ комментариев и 12,8 КБ gzip уехали пользователю и никто не заметил', patches: [{ file: 'scripts/css-template-minifier.mjs', find: " if (!id.endsWith('.ts') || !CSS_TAG" + '.test(code)) return null;', replace: " if (!id.endsWith('.ts') || !code.includes('css" + String.fromCharCode(96) + "')) return null;", }], }, { id: 'css-minifier-eats-required-space', guard: 'node demo/smoke_css_minifier_semantics.mjs', because: '#526: минификация 23 КБ CSS включилась впервые — свидетель обязан ловить ' + 'съеденный значащий пробел (потомковый комбинатор, аргумент calc), а не только ' + 'считать сэкономленные байты', patches: [{ file: 'scripts/css-template-minifier.mjs', find: ' if (pendingSpace && out && !TIGHT_AFTER.has(out.at(-1))' + ' && !TIGHT_BEFORE.has(char)) {\n' + " out += ' ';\n" + ' }', replace: ' void pendingSpace; // mutant: значащий пробел больше не восстанавливается', }], }, { id: 'marker-shadow-animates-again', guard: 'node demo/smoke_marker_shadow_transitions.mjs', because: '#524: тень маркера выражена в контейнерных единицах — с переходом любой пересчёт ' + 'контейнерных запросов запускает некомпозируемый переход на каждом маркере; в профиле ' + 'владельца 61 штука разом дала 9,4 к/с и кадры по 149 мс', patches: [{ file: 'src/styles/devices.styles.ts', find: ' transition: border-color .15s, opacity' + ' .2s;', replace: ' transition: border-color .15s, box-shadow .15s, opacity .2s;', }], }, { id: 'anchor-region-narrows-ambiguous-anchor', guard: 'node --test --test-name-pattern="#518 AC4" test/mutation-gate.test.mjs', because: '#518: якорь, найденный не ровно один раз, значит «реестр отстал от кода» — ' + 'судить такого свидетеля по окрестности первого попавшегося вхождения значит ' + 'молча сузить проверку там, где она и так под вопросом', patches: [{ file: 'scripts/mutation-selection.mjs', find: ' if (!find || text.split(find)' + '.length - 1 !== 1) return null;', replace: ' if (!find) return null;', }], }, { id: 'anchor-radius-collapses', guard: 'node --test --test-name-pattern="#518 AC2" test/mutation-gate.test.mjs', because: '#518: нулевой радиус оставляет в отпечатке одни строки патча — правка соседней ' + 'строки перестаёт перегонять свидетеля, и «поймано» начинает значить «не проверяли»', patches: [{ file: 'scripts/mutation-selection.mjs', find: 'export const ANCHOR_RADIUS' + '_LINES = 40;', replace: 'export const ANCHOR_RADIUS' + '_LINES = 0;', }], }, { id: 'anchor-select-drops-guard-inputs', guard: 'node --test --test-name-pattern="#518 AC1/AC2 \\(отбор\\)" test/mutation-gate.test.mjs', because: '#518: сужение касается ТОЛЬКО стороны патча; у гарда якоря нет, и его правка ' + 'обязана отбирать свидетеля при любых известных областях (#475 AC2 иначе отменяется)', patches: [{ file: 'scripts/mutation-selection.mjs', find: ' || inputsOf(m.guard).some((file) =>' + ' changed.has(file)));', replace: ' || (!ranges && inputsOf(m.guard).some((file) => changed.has(file))));', }], }, { id: 'anchor-touched-defaults-to-skip', guard: 'node --test --test-name-pattern="#518 AC1/AC2 \\(отбор\\)" test/mutation-gate.test.mjs', because: '#518: файл в диффе без прочитанных ханков — незнание, а не доказательство; ' + 'ответ по умолчанию обязан быть «гнать», иначе непрочитанный дифф тихо пропускает свидетелей', patches: [{ file: 'scripts/mutation-selection.mjs', find: ' if (!hunks || !hunks.length)' + ' return true;', replace: ' if (!hunks || !hunks.length) return false;', }], }, // #650: a --test-name-pattern that matches nothing is a vacuous witness. { id: 'empty-test-selection-reads-as-survived', guard: "node --test --test-name-pattern=\"#650 (clean run|mutant run|the installed node)\" test/mutation-guard-outcome.test.mjs", because: '#650: a green oracle that executed no named test proves nothing; calling it ' + 'survived blames the code, and a clean run calls it a healthy witness', patches: [{ file: 'scripts/mutation-guard-outcome.mjs', find: " const empty = phase === 'oracle' ? emptyTestSelection(result?.command, outputOf(result)) : null;", replace: " const empty = null;" }], }, { id: 'static-test-selection-accepts-unmatched', guard: "node --test --test-name-pattern=\"#650 static check\" test/mutation-guard-outcome.test.mjs", because: '#650: mutation-gate --check must name an unmatched name filter before CI spends a shard on it', patches: [{ file: 'scripts/mutation-guard-outcome.mjs', find: " if (names.some((name) => regexps.some((regexp) => regexp.test(name)))) continue;", replace: " if (names.length || !names.length) continue;" }], }, { id: 'shell-words-drops-regex-escape', guard: "node --test --test-name-pattern=\"#650 (selection|static check)\" test/mutation-guard-outcome.test.mjs", because: '#650: inside double quotes the shell keeps a backslash before ( — dropping it turns an ' + 'escaped pattern into a group and flags healthy guards as empty', patches: [{ file: 'scripts/mutation-guard-outcome.mjs', find: " if (char === '\\\\' && quote === '\"' && /[$`\"\\\\\\n]/.test(text[index + 1] || '')) {", replace: " if (char === '\\\\' && quote === '\"' && index + 1 < text.length) {" }], }, { id: 'ledger-version-sensitive', guard: 'node --test --test-name-pattern="#481 AC1" test/mutation-gate.test.mjs', because: 'a release bump touches houseplan-card.ts and houseplan-editor-runtime.ts; without ' + 'version normalisation every witness patching them re-runs on every candidate — the #480 timeout (#481)', patches: [{ file: 'scripts/mutation-evidence.mjs', find: ' hash.update(normalize(' + 'valueOf()));', replace: ' hash.update(valueOf());', }], }, { id: 'ledger-written-at-end-only', guard: 'node --test --test-name-pattern="#481 AC3" test/mutation-gate.test.mjs', because: 'the ledger must be written after EACH caught witness: a shard cancelled by the next push ' + 'or killed by the timeout must keep what it proved, or the snowball returns (#481)', patches: [{ file: 'scripts/mutation-evidence.mjs', find: ' ledger.caught[mutant.id] = { fingerprint, proof };\n' + ' mkdirSync(dirname(file), { recursive: true });\n writeFileSync(file,', replace: ' ledger.caught[mutant.id] = { fingerprint, proof };\n' + ' mkdirSync(dirname(file), { recursive: true });\n' + ' if (Object.keys(ledger.caught).length > 1) writeFileSync(file,', }], }, { id: 'mutation-outcome-setup-counts-as-assertion', guard: 'node --test test/mutation-guard-outcome.test.mjs', because: 'a compile or preparation failure before the declared oracle must never be reusable ' + 'proof that the named assertion executed and killed the mutant (#550 AC1-AC3)', patches: [{ file: 'scripts/mutation-guard-outcome.mjs', find: " if (phase === 'setup') {", replace: " if (false && phase === 'setup') {", }], }, { id: 'mutation-ledger-accepts-setup-proof', guard: 'node --test --test-name-pattern="#550 fixture: clean setup|#481 AC3" ' + 'test/mutation-guard-outcome.test.mjs test/mutation-gate.test.mjs', because: 'ledger reuse may only carry an assertion or an explicitly declared compile-time ' + 'witness; admitting setup failures makes a transient broken runner green forever (#550 AC4)', patches: [{ file: 'scripts/mutation-evidence.mjs', find: 'const validProof = (proof) => Object.values(MUTATION_PROOF)' + '.includes(proof);', replace: "const validProof = (proof) => proof === 'setup' || Object.values(MUTATION_PROOF)" + '.includes(proof);', }], }, { id: 'manifest-drops-workflow-input', guard: 'node --test --test-name-pattern="toolchain input of every job" test/gate-reuse.test.mjs', because: 'validate.yml decides what a job checks; a job whose key ignores its own workflow is ' + 'reused after a step is added and the new step never runs (#492 §5.4, the #430 class)', patches: [{ file: 'scripts/check-inputs.mjs', find: "const WORKFLOW = ['.github/workflows/validate.yml'];", replace: 'const WORKFLOW = []; // mutant: the workflow is nobody\'s input', }], }, { id: 'classify-unknown-input-is-unaffected', guard: 'node --test --test-name-pattern="неизвестный исполняемый вход расширяет" test/classify-changes.test.mjs', because: 'a changed executable file no check claims must widen the run to the full set; treating ' + '"unknown" as "unaffected" is exactly how relay tests stayed unrun (#492 §5.2)', patches: [{ file: 'scripts/check-inputs.mjs', find: ' if (unknown.length) for (const name of CHECK_NAMES) affected.add(name);', replace: ' // mutant: unknown inputs widen nothing', }], }, { id: 'reuse-backend-hashes-ui', guard: 'node --test --test-name-pattern="a version bump changes all of them" test/gate-reuse.test.mjs', because: 'backend does not execute the UI; hashing src/** into its key reverts the AC6 closure ' + 'and rebuilds the "every frontend edit reruns pytest" skew (#492 AC6)', patches: [{ file: 'scripts/check-inputs.mjs', find: " roots: ['custom_components/**/*.py', 'custom_components/houseplan/manifest.json', 'scripts/support-relay/**/*.py',", replace: " roots: ['src/**', 'custom_components/**/*.py', 'custom_components/houseplan/manifest.json', 'scripts/support-relay/**/*.py',", }], }, { id: 'backend-dynamic-inputs-dropped', guard: 'node --test --test-name-pattern="#542" test/check-inputs.test.mjs test/gate-reuse.test.mjs', because: 'backend pytest reads selected TS sources and browser fixtures through os.path.join and a ' + 'Python f-string dynamic import; without exact roots selection and reuse silently miss them (#542)', patches: [{ file: 'scripts/check-inputs.mjs', find: ' ...BACKEND_DYNAMIC_INPUTS,', replace: ' // mutant: dynamic cross-runtime inputs are absent', }], }, { id: 'furniture-assets-dropped-from-frontend-inputs', guard: 'node --test --test-name-pattern="#671: весь пакет мебели" test/check-inputs.test.mjs', because: 'тесты frontend читают assets/furniture по составным путям; без явного корня ' + 'правка пакета не выбирает frontend и не запускает сверку generated-модулей (#671)', patches: [{ file: 'scripts/check-inputs.mjs', find: " roots: [...BUILD_INPUTS, 'assets/furniture/**', 'test/**',", replace: " roots: [...BUILD_INPUTS, 'test/**', // mutant: furniture package is not a frontend input", }], }, { id: 'assets-data-root-not-guarded', guard: 'node --test --test-name-pattern="#671: новый assets" test/check-inputs.test.mjs', because: 'лист покрытия, который смотрит только на исполняемые расширения, снова ' + 'пропустит любой новый бинарный или текстовый пакет данных под assets/** (#671)', patches: [{ file: 'scripts/check-inputs.mjs', find: "export const GUARDED_DATA_ROOTS = ['assets'];", replace: 'export const GUARDED_DATA_ROOTS = []; // mutant: asset data is not audited', }], }, { id: 'harness-directory-docs-leak-into-inputs', guard: 'node --test --test-name-pattern="#672" test/check-inputs.test.mjs test/gate-reuse.test.mjs', because: '#672: строка-каталог обозначает runtime-корпус, а не документацию рядом с ним; ' + 'без фильтра README снова выбирает тяжёлые jobs и меняет их reuse keys', patches: [{ file: 'scripts/check-inputs.mjs', find: ' if (f.startsWith(`${ref}/`) && !BINARY.test(f) && !DIRECTORY_DOCUMENTATION.test(f) && !isBaselineOverlay(f)) { note(f, file); seen.add(f); }', replace: ' if (f.startsWith(`${ref}/`) && !BINARY.test(f) && !isBaselineOverlay(f)) { note(f, file); seen.add(f); }', }], }, { id: 'explicit-markdown-read-dropped-from-inputs', guard: 'node --test --test-name-pattern="#672: документация каталога" test/check-inputs.test.mjs', because: '#672: фильтр каталогов не должен скрывать Markdown, который код читает по точному пути', patches: [{ file: 'scripts/check-inputs.mjs', find: ' for (const m of text.matchAll(REL_DOC_LITERAL)) {', replace: " for (const m of ''.matchAll(REL_DOC_LITERAL)) { // mutant: explicit docs disappear", }], }, { id: 'browser-protocol-recaptures-guard-readme', guard: 'node --test --test-name-pattern="#672: README каталогов" test/check-inputs.test.mjs', because: '#672: browser protocol must name executable guards, not every document in demo/guard', patches: [{ file: 'scripts/check-inputs.mjs', find: " 'demo/editor-runtime-compat.mjs', 'demo/iso-runtime-compat.mjs', 'demo/guard/*.mjs', 'demo/helpers/hp-test.mjs'];", replace: " 'demo/editor-runtime-compat.mjs', 'demo/iso-runtime-compat.mjs', 'demo/guard/**', 'demo/helpers/hp-test.mjs'];", }], }, { id: 'golden-root-recaptures-golden-readme', guard: 'node --test --test-name-pattern="#672: README каталогов" test/check-inputs.test.mjs', because: '#672: golden needs the accepted baseline overlay, not a broad root that also owns README', patches: [{ file: 'scripts/check-inputs.mjs', find: ' roots: [...BUILD_INPUTS, ...BASELINE_OVERLAY, ...BROWSER_PROTOCOL, ...REUSE_PROTOCOL, ...WORKFLOW],', replace: " roots: [...BUILD_INPUTS, 'demo/golden/**', ...BROWSER_PROTOCOL, ...REUSE_PROTOCOL, ...WORKFLOW],", }], }, { id: 'guard-inputs-ignore-wrapper-defaults', guard: 'node --test --test-name-pattern="#492 §8.2" test/mutation-gate.test.mjs', because: 'ten backend wrappers run tests_backend/test_ha_import_export.py by default without naming ' + 'it; dropping the declared default hides that test from selection and fingerprints again (#492 §6.1)', patches: [{ file: 'scripts/mutation-selection.mjs', find: ' const declared = explicit ? [] : wrappers' + '.flatMap((file) => wrapperInputs(file, read));', replace: ' const declared = []; // mutant: wrapper defaults are invisible', }], }, { id: 'registry-diff-not-selected', guard: 'node --test --test-name-pattern="#492 §6.4: дифф только по реестру" test/mutation-gate.test.mjs', because: 'a new or changed mutant definition must run on the push that adds it; selecting only by ' + 'patch and guard files leaves a registry-only diff green without running the witness (#492 §6.4)', patches: [{ file: 'scripts/mutation-selection.mjs', find: ' byRegistry = mutants' + '.filter((m) => delta.changed.includes(m.id));', replace: ' byRegistry = []; // mutant: definitions never select', }], }, { id: 'merge-pushes-unvalidated-candidate', guard: 'node --test --test-name-pattern="эксперимент аудита" test/merge-candidate.test.mjs', because: 'after a clean rebase onto a moved dev the candidate is a tree nobody checked; pushing ' + 'it without a green Validate on that SHA is the false-green the audit reproduced (#492 §4)', patches: [{ file: 'scripts/merge-candidate.mjs', find: " const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });", replace: " const { result, url } = { result: 'green', url: 'skipped' }; // mutant: no validation", }], }, { id: 'merge-ignores-lease-rejection', guard: 'node --test --test-name-pattern="lease отклонён" test/merge-candidate.test.mjs', because: 'dev moving again between Validate and push must restart the candidate, not be ' + 'reported as merged: a rejected lease treated as success leaves S8-merged on code not in dev (#492 AC2)', patches: [{ file: 'scripts/merge-candidate.mjs', find: " const pushed = ops.pushWithLease(candidate, 'dev', devNow);\n decision = decideMerge(", replace: " const pushed = ops.pushWithLease(candidate, 'dev', devNow) || true; // mutant: lease never rejected\n decision = decideMerge(", }], }, { id: 'nightly-does-not-wait', guard: 'node --test --test-name-pattern="nightly ждёт запущенный Validate" test/nightly-workflow.test.mjs', because: 'a nightly that returns green at dispatch time hides a red full run; the job must wait ' + 'for the child and inherit its conclusion (#492 §7)', patches: [{ file: '.github/workflows/_nightly.yml', find: ' gh run watch "$run_id" --repo "$REPO" --exit-status --interval 30', replace: ' echo "watching skipped" # mutant: dispatch counted as success', }], }, { id: 'changed-selection-ignores-guard-files', guard: 'node --test --test-name-pattern="#475 AC2" test/mutation-gate.test.mjs', because: 'a witness also rots when its guard changes and stops reaching the mutated branch; ' + 'selecting by patch file alone lets that class survive until the weekly full run (#475)', patches: [{ file: 'scripts/mutation-selection.mjs', find: ' || inputsOf(m.guard)' + '.some((file) => changed.has(file)));', replace: ' || false);', }], }, { id: 'changed-selection-matches-any-token', guard: 'node --test --test-name-pattern="#475 AC3" test/mutation-gate.test.mjs', because: 'treating every token of the guard command as a file turns pattern fragments ' + 'like `b.mjs"` into guard files and selects mutants for unrelated diffs (#475)', patches: [{ file: 'scripts/mutation-selection.mjs', find: " if (!/^[\\w./-]+\\.(mjs|py)$/.test(bare) || bare.startsWith('-')) continue;", replace: " if (!/\\.(mjs|py)/.test(bare)) continue;", }], }, { id: 'review-comment-source-ignores-issue-number', guard: 'node --test --test-name-pattern="по документу ЭТОЙ задачи|чужой номер задачи" ' + 'test/review-doc-guard.test.mjs', because: 'matching a bare stage marker counts any comment that merely mentions another ' + "issue's review document as a verdict of this one — #454 gave its own first code " + 'review round number r3 that way, and a contaminated yellow would burn the §4 budget ' + 'without a single real cycle (#89)', patches: [{ file: 'scripts/review-doc-guard.mjs', find: ' const own = new RegExp(`${marker}-${num}(?![0-9])`);', replace: ' const own = new RegExp(marker);', }], }, { id: 'review-round-counts-files-not-max', guard: 'node --test --test-name-pattern="от максимума номеров" test/review-doc-guard.test.mjs', because: 'counting how many round documents exist instead of taking the highest number ' + 'hands out an occupied file name whenever the numbering has a hole — and a hole is ' + 'exactly what the collision this fixes leaves behind (#454)', patches: [{ file: 'scripts/review-doc-guard.mjs', find: ' return known.length ? Math.max(...known) + 1 : 1;', replace: ' return known.length + 1;', }], }, { id: 'review-round-drops-file-source', guard: 'node --test --test-name-pattern="прожитая уже с исправлением|момент коллизии" ' + 'test/review-doc-guard.test.mjs', because: 'without the published documents the counter is back to reading prose, and a ' + 'verdict that never named its file silently reuses the round number (#454, #449)', patches: [{ file: 'scripts/review-doc-guard.mjs', find: ' const attemptFiles = attemptFromRounds(rounds);', replace: ' const attemptFiles = 1;', }], }, { id: 'review-round-drops-comment-insurance', guard: 'node --test --test-name-pattern="отказ публикации" test/review-doc-guard.test.mjs', because: 'the publish step can fail after the verdict is already posted; trusting only ' + 'the documents then loses a spent cycle and hands out a used round number (#454)', patches: [{ file: 'scripts/review-doc-guard.mjs', find: ' attempt: Math.max(attemptFiles, attemptComments),', replace: ' attempt: attemptFiles,', }], }, { id: 'review-doc-guard-matches-by-substring', guard: 'node --test --test-name-pattern="соседний каталог" test/review-doc-guard.test.mjs', because: 'сравнение подстрокой пускает docs/reviews-old и docs/reviewsx: allowlist, который ' + 'ошибается в свою пользу, не защищает ни от чего (#365)', patches: [{ file: 'scripts/review-doc-guard.mjs', find: " const prefixes = allowlist.map((item) => (item.endsWith('/') ? item : `${item}/`));", replace: " const prefixes = allowlist.map((item) => item.replace(/\\/$/, ''));", }], }, { id: 'review-doc-guard-allows-empty-diff', guard: 'node --test --test-name-pattern="пустой дифф" test/review-doc-guard.test.mjs', because: 'пустой дифф означает, что документа нет: прежняя редакция шага выходила тут с ' + 'нулём, и вердикт ревью оставался без артефакта (#171, #365)', patches: [{ file: 'scripts/review-doc-guard.mjs', find: ' if (!cleaned.length) {', replace: ' if (false) {', }], }, { id: 'no-new-any-judges-every-line', guard: 'node --test --test-name-pattern="нетронутой строке гейт не блокирует" ' + 'test/no-new-any.test.mjs', because: 'гейт, судящий все строки вместо добавленных, краснеет на 1034 существующих ' + 'вхождениях и будет отключён в первый же день — а долг типизации снимается при ' + 'извлечении подсистем, не разовой заменой (#342)', patches: [{ file: 'scripts/no-new-any.mjs', find: ' if (!file.addedLines.has(line)) continue;', replace: ' if (false) continue;', }], }, { id: 'no-new-any-accepts-bare-marker', guard: 'node --test --test-name-pattern="только с конкретной причиной" ' + 'test/no-new-any.test.mjs', because: 'голый `// any-ok` — это не обоснование, а способ обойти гейт одной строкой; ' + 'без проверки причины исключение перестаёт что-либо значить (#342)', patches: [{ file: 'scripts/no-new-any.mjs', find: ' if (exemption?.ok) continue;', replace: ' if (exemption) continue;', }], }, { id: 'private-writes-ignores-update-expressions', guard: 'node --test --test-name-pattern="во всех формах" test/no-new-private-writes.test.mjs', because: '`c._cfgEpoch++` — одна из самых частых записей смоков в приватное состояние ' + '(202 на 22.09); гейт, не видящий ++/--, пропускает ровно тот обход, ради которого ' + 'заведён (#629 AC1)', patches: [{ file: 'scripts/no-new-private-writes.mjs', find: ' if ((ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && UPDATE_OPERATORS.has(node.operator)) {', replace: ' if (false) {', }], }, { id: 'private-writes-credits-any-field', guard: 'node --test --test-name-pattern="правка зачитывается только" test/no-new-private-writes.test.mjs', because: 'зачёт правки без сверки поля превращает любую уборку старой записи в индульгенцию ' + 'на новую запись в другое поле — гейт перестаёт держать приращение (#629 AC2)', patches: [{ file: 'scripts/no-new-private-writes.mjs', find: 'const creditKey = (site) => `${site.kind}:${site.field}`;', replace: 'const creditKey = (site) => site.kind;', }], }, { id: 'private-writes-accepts-bare-marker', guard: 'node --test --test-name-pattern="private-ok проходит только" test/no-new-private-writes.test.mjs', because: 'голый `// private-ok` — обход гейта одной строкой; без проверки причины исключение ' + 'перестаёт что-либо значить, как и у any-ok (#342, #629 AC3)', patches: [{ file: 'scripts/no-new-private-writes.mjs', find: ' if (exempt && exempt.ok) continue;', replace: ' if (exempt) continue;', }], }, { id: 'private-writes-skips-covered-calls', guard: 'node --test --test-name-pattern="вызов, покрытый фасадом" test/no-new-private-writes.test.mjs', because: '265 вызовов `_setMode(` продолжали бы расти рядом с фасадом, который нажимает ' + 'настоящую вкладку режима; без G5 фасад необязателен (#629 AC4)', patches: [{ file: 'scripts/no-new-private-writes.mjs', find: ' if (ts.isCallExpression(node)) {', replace: ' if (false) {', }], }, { id: 'invariant-hidden-counts-corner-touch', guard: 'node --test --test-name-pattern="касание углом" test/model-invariants.test.mjs', because: 'перегородка, продолжающая стену за угол, законна: общего с ребром у неё ровно ' + 'точка. Порог в шаг решётки — единственное, что отделяет находку от ложного ' + 'срабатывания на каждом углу плана (#296)', patches: [{ file: 'scripts/model-invariants.mjs', find: ' if (overlap > GRID_STEP_N && (!best || overlap > best.overlap)) {', replace: ' if (overlap >= 0 && (!best || overlap > best.overlap)) {', }], }, { id: 'invariant-hidden-tolerates-two-point-draft', guard: 'node --test --test-name-pattern="черновик из двух точек" ' + 'test/model-invariants.test.mjs', because: 'ровно это условие пропускает вырожденный черновик в align-grid: `points.length ' + '>= 2` считает контур из двух точек годным, а комнатой он не станет никогда — ' + 'и остаётся невидимым препятствием для ресайза (#296)', patches: [{ file: 'scripts/model-invariants.mjs', find: ' if (points.length < 3) {', replace: ' if (points.length < 2) {', }], }, { id: 'invariant-hidden-ignores-collinearity', guard: 'node --test --test-name-pattern="перегородка поперёк комнаты" ' + 'test/model-invariants.test.mjs', because: 'перегородка поперёк комнаты — единственный смысл существования перегородки; ' + 'без проверки поперечного отклонения проверка ловит её и будет отключена в первую ' + 'неделю (#296)', patches: [{ file: 'scripts/model-invariants.mjs', find: ' if (across(c) > EDGE_TOLERANCE || across(d) > EDGE_TOLERANCE) return 0;', replace: ' if (false) return 0;', }], }, { id: 'invariant-roles-sample-endpoints', guard: 'node --test --test-name-pattern="реальные планы проекта эту проверку" ' + 'test/model-invariants.test.mjs', because: 'конец записи — узел, а не участок: там стена законно касается двух комнат, ' + 'и включение концов в выборку даёт ложное срабатывание на каждой наружной стене, ' + 'упирающейся в общую (#287)', patches: [{ file: 'scripts/model-invariants.mjs', find: ' for (let i = 1; i < samples; i++) {', replace: ' for (let i = 0; i <= samples; i++) {', }], }, { id: 'invariant-keys-cry-wolf', guard: 'node --test --test-name-pattern="старый и неразбираемый compatibility key" ' + 'test/model-invariants.test.mjs', because: 'valid exact endpoints prove the same physical span before every legacy key ' + 'fallback; calling a stale or unparsable index a violation would cry wolf (#258)', patches: [{ file: 'scripts/model-invariants.mjs', find: " notes.push({ invariant: 'wall_keys', kind: 'stale_wall_key', owner,", replace: " violations.push({ invariant: 'wall_keys', kind: 'stale_wall_key', owner,", }], }, { id: 'invariant-keys-hide-stale-observation', guard: 'node --test --test-name-pattern="старый и неразбираемый compatibility key" ' + 'test/model-invariants.test.mjs', because: 'a mismatched compatibility key is repairable data debt; hiding the observation ' + 'would make Optimize repair invisible and let the two representations accumulate (#258)', patches: [{ file: 'scripts/model-invariants.mjs', find: ' if (wall.key === expected) continue;', replace: ' continue;', }], }, { id: 'invariant-accepts-dead-space-reference', guard: 'node --test --test-name-pattern="маркер на удалённое пространство" ' + 'test/model-invariants.test.mjs', because: 'маркер, привязанный к удалённому пространству, исчезает с плана молча (#244): ' + 'проверка ссылок обязана считать это дефектом операции, а не особенностью данных (#254)', patches: [{ file: 'scripts/model-invariants.mjs', find: ' if (space && !spaceIds.has(space)) {\n add(\'marker_space\'', replace: ' if (false) {\n add(\'marker_space\'', }], }, { id: 'wall-face-apply-skips-overlap-guard', guard: 'node demo/smoke_wall_face_overlap.mjs', because: 'вторая проверка при создании — не дубль первой, а defense-in-depth из #173 §10.3: ' + 'решение принимается в диалоге, а геометрия к моменту применения может стать другой ' + '(#177)', patches: [{ file: 'src/houseplan-card.ts', find: ' const clash = existingRooms.find((room) => room.id !== ownSplit', replace: ' const clash = [].find((room) => room.id !== ownSplit', }], }, { id: 'docs-fixture-splits-posix-only', guard: 'node --test --test-name-pattern="не зависит от разделителя платформы" ' + 'test/docs-accept.test.mjs', because: 'фикстура приёмки разбирала путь только по «/» и на Windows отдавала весь путь ' + 'вместо имени файла: три проверки краснели на верной реализации, а Linux этого не ' + 'видел вовсе — регресс обязан краснеть на любой платформе (#247)', patches: [{ file: 'test/docs-accept.test.mjs', find: "export const basename = (path) => String(path).split(/[\\\\/]/).filter(Boolean).pop() ?? '';", replace: "export const basename = (path) => String(path).split('/').pop() ?? '';", }], }, { id: 'docs-accept-takes-any-chromium', guard: 'node --test --test-name-pattern="без названного Chromium" test/docs-accept.test.mjs', because: 'набор, снятый другим браузером, переписывает все десять картинок без единого ' + 'содержательного изменения, поэтому окружение съёмки — часть доказательства, а не ' + 'украшение манифеста (#246)', patches: [{ file: 'scripts/docs-accept.mjs', find: " if (typeof manifest.chromium !== 'string' || !manifest.chromium.trim())", replace: ' if (false)', }], }, { id: 'docs-accept-copies-before-checking', guard: 'node --test --test-name-pattern="отсутствующий в артефакте файл" ' + 'test/docs-accept.test.mjs', because: 'половина принятого набора хуже непринятого: на плане окажется картинка от одного ' + 'дерева рядом с манифестом от другого, и check-docs покажет одну ошибку вместо десяти ' + '(#246)', patches: [{ file: 'scripts/docs-accept.mjs', find: " throw new Error(`${scenario.id}: в артефакте нет файла ${entry.file || '(без имени)'}`);", replace: ' continue;', }], }, { id: 'docs-fingerprint-sees-product-version', guard: 'node --test --test-name-pattern="не трогает отпечаток скриншотов" ' + 'test/source-fingerprint.test.mjs', because: 'номер версии продукта на скриншотах не нарисован, и если отпечаток документации ' + 'снова начнёт его видеть, каждый релизный коммит будет оставлять job docs красным — ' + 'красный гейт, который «всегда такой», перестают читать (#245)', patches: [{ file: 'scripts/source-fingerprint.mjs', find: ' ? (text) => text.split(version).join(\'0.0.0-product-version\')', replace: ' ? (text) => text', }], }, { id: 'smoke-select-drops-registered-link', guard: 'node --test --test-name-pattern="держится на зарегистрированной связи" ' + 'test/smoke-select.test.mjs', because: 'связь «изменённый контракт → смок, который его не называет» доказать поиском ' + 'нельзя, и держится она только на реестре: без записи выборка по диффу #234 снова ' + 'промолчит о smoke_wall_thickness_transition — том самом классе смоков, на котором ' + '#234 потерял регресс (#241)', patches: [{ file: 'scripts/smoke-links.mjs', find: " symbols: ['chainSegmentCms', 'wallChainSegments', '_wallChainSegmentCms', '_closingWallCm'],", replace: ' symbols: [],', }], }, { id: 'inner-span-ignores-neighbour-thickness', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="innerEdgeSpan measures between wall faces" ' + 'test/wall-thickness.test.mjs', because: 'an edge is cut by the inner faces of its NEIGHBOURS, not by its own thickness: a ' + 'thin side between two thick walls loses 2x15, not 2x7.5, and reading own thickness at ' + 'both ends gives a number no tape measure confirms (#233 AC2)', patches: [{ file: 'src/wall-thickness.ts', find: ' const o = Math.max(0, Number(offsets[edge]) || 0);', replace: ' const o = own;', }], }, { id: 'inner-span-shortens-a-passage', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="keeps the centreline where there is no wall" ' + 'test/wall-thickness.test.mjs', because: 'a passage has no face to measure from, and insetContour leaves that joint as a flat ' + 'cap, so shortening an open side would split length from area again at a new boundary ' + '(#233, spec review r1/H1)', patches: [{ file: 'src/wall-thickness.ts', find: ' if (!(own > 0)) return centre;', replace: ' if (false) return centre;', }], }, { id: 'inner-span-reads-whole-edge-thickness', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="ownEdgeOffsets reads the atomic profile" ' + 'test/wall-thickness.test.mjs', because: 'thicknessCmAt returns 0 for a whole-edge query against a partially set thickness, so ' + 'reading it instead of the atomic profile would silently stop shortening split-thickness ' + 'edges (#233, spec review r1/H2)', patches: [{ file: 'src/wall-thickness.ts', // Условие ложно в рантайме, но не статически: с литеральным `false` // TypeScript признаёт блок мёртвым и ТЕРЯЕТ сужение `profile` выше, из-за // чего мутант падал на компиляции и не проверял ничего (#568). find: ' if (distToSeg(mid[0], mid[1], p0[0], p0[1], p1[0], p1[1]) <= eps) {', replace: ' if (eps < 0) {', }], }, { id: 'snapn-returns-input-near-node', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="snapN returns the exact nearest node" ' + 'test/align-grid.test.mjs', because: 'explicit Optimize must replace a stored ULP tail with the exact nearest grid node ' + 'instead of preserving the noisy input merely because its displacement is visually tiny', patches: [{ file: 'src/align-grid.ts', find: ' return Math.round(v / GRID_STEP_N) * GRID_STEP_N;', replace: ' const s = Math.round(v / GRID_STEP_N) * GRID_STEP_N;\n' + ' return Math.abs(s - v) <= EPS ? v : s;', }], }, { id: 'optimize-storage-boundary-removed', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="Optimize canonicalizes the six-room ULP source" ' + 'test/plan-optimizer.test.mjs', because: 'Optimize must remove near-node tails before visible Align so storage-only cleanup ' + 'is not misreported as a user-visible move and the exact candidate survives reload (#291)', patches: [{ file: 'src/plan-optimizer.ts', find: ' canonicalizeConfigGeometryInPlace(config);\n' + ' canonicalizeLayoutGeometryInPlace(references.layout);', replace: ' // mutant: skip the pre-Align lattice boundary', }], }, { id: 'optimize-config-storage-half-raw', guard: 'node scripts/backend-test-guard.mjs storage_helpers_are_the_final_canonical_barrier ' + 'tests_backend/test_coordinate_canonicalization.py', because: 'the durable config half must remain the exact canonical target recorded by ' + 'Optimize intent even for internal writers which bypass WebSocket schema (#248)', patches: [{ file: 'custom_components/houseplan/store.py', find: ' canonical_config = canonicalize_config_geometry(config)', replace: ' canonical_config = config', }], }, { id: 'optimize-layout-storage-half-raw', guard: 'node scripts/backend-test-guard.mjs storage_helpers_are_the_final_canonical_barrier ' + 'tests_backend/test_coordinate_canonicalization.py', because: 'the durable layout half must remain the exact canonical target recorded by ' + 'Optimize intent even for internal writers which bypass WebSocket schema (#248)', patches: [{ file: 'custom_components/houseplan/store.py', find: ' out["layout"] = canonicalize_layout_geometry(layout)', replace: ' out["layout"] = layout', }], }, { id: 'union-quantization-removed', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="boolean input normalization|six-room ULP" ' + 'test/physical-geometry.test.mjs', because: 'ordinary split/merge double tails must collapse at the shared boolean boundary ' + 'instead of making one room erase every Glow clip in its space', patches: [{ file: 'src/physical-geometry.ts', find: ' const qx = Math.round(x / step) * step;\n' + ' const qy = Math.round(y / step) * step;', replace: ' const qx = x;\n const qy = y;', }], }, { id: 'schema-quantization-removed', guard: 'node scripts/backend-test-guard.mjs backend_schemas_apply ' + 'tests_backend/test_coordinate_canonicalization.py', because: 'backend schemas are the public write door for cards, imports and manual clients; ' + 'removing their canonicalisation would let the same ULP noise enter every future writer', patches: [{ file: 'custom_components/houseplan/validation.py', find: ' extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading\n' + ' ),\n canonicalize_config_geometry,\n _config_wall_segment_invariants,\n)', replace: ' extra=vol.ALLOW_EXTRA, # unknown (legacy) keys do not break loading\n' + ' ),\n lambda value: value,\n _config_wall_segment_invariants,\n)', }], }, { id: 'frontend-writes-raw-coords', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="frontend write paths" ' + 'test/coordinate-canonicalization.test.mjs', because: 'a server-only fix leaves the open card rendering its noisy mutable config until ' + 'reload, so the current session can still reproduce the geometry failure after Save', patches: [{ file: 'src/houseplan-card.ts', find: ' const candidate = canonicalizeConfigGeometry(this._serverCfg);', replace: ' const candidate = this._serverCfg;', }], }, { id: 'image-box-frontend-canonicalization-omitted', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="decor box catalog" ' + 'test/coordinate-canonicalization.test.mjs', because: 'the decor type can know about an image while a handwritten traversal silently ' + 'leaves its x/y/w/h outside the canonical write barrier (#431 AC4)', patches: [{ file: 'src/coordinate-canonicalization.ts', find: ' && (DECOR_BOX_KINDS as readonly string[]).includes(value);', replace: " && value !== 'image'\n" + ' && (DECOR_BOX_KINDS as readonly string[]).includes(value);', }], }, { id: 'image-box-python-canonicalization-omitted', guard: 'node scripts/backend-test-guard.mjs decor_box_catalog_matches_shared_contract ' + 'tests_backend/test_coordinate_canonicalization_pure.py', because: 'the Python schema and storage boundary must mirror the complete frontend box ' + 'catalog instead of accepting image floating-point tails from older clients (#431 AC4)', patches: [{ file: 'custom_components/houseplan/coordinate_canonicalization.py', find: 'DECOR_BOX_KINDS = ("rect", "ellipse", "furniture", "image")', replace: 'DECOR_BOX_KINDS = ("rect", "ellipse", "furniture")', }], }, { id: 'quantization-hits-allowlist', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="scalar\\+lattice fixture contract" ' + 'test/coordinate-canonicalization.test.mjs', because: 'presentation and calibration values are deliberately outside geometry; widening ' + 'the allow-list silently changes user data that has no ULP topology problem', patches: [{ file: 'src/coordinate-canonicalization.ts', find: " for (const marker of records(root.markers)) scalarFields(marker, ['angle']);", replace: " for (const marker of records(root.markers)) scalarFields(marker, ['angle', 'size']);", }], }, { id: 'lattice-round-truncates', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="all 4801 lattice nodes" ' + 'test/coordinate-canonicalization.test.mjs', because: 'truncating toward zero maps every negative and fractional node to the wrong exact ' + 'double, recreating topology drift even though the output still looks grid-like (#291)', patches: [{ file: 'src/coordinate-canonicalization.ts', find: ' const scaled = value * LATTICE_GRID_N;\n' + ' const nearest = Math.round(scaled);\n' + ' const deviation = Math.abs(scaled - nearest);', replace: ' const scaled = value * LATTICE_GRID_N;\n' + ' const nearest = Math.trunc(scaled);\n' + ' const deviation = Math.abs(scaled - nearest);', }], }, { id: 'python-lattice-round-truncates', guard: 'node scripts/backend-test-guard.mjs all_4801_lattice_nodes ' + 'tests_backend/test_coordinate_canonicalization_pure.py', because: 'the backend must use the JavaScript Math.round tie direction and nearest-node ' + 'semantics; truncation would make old-client writes diverge from the card (#291)', patches: [{ file: 'custom_components/houseplan/coordinate_canonicalization.py', find: ' nearest = math.floor(scaled + 0.5)', replace: ' nearest = math.trunc(scaled)', }], }, { id: 'lattice-noise-threshold-too-small', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="all 4801 lattice nodes" ' + 'test/coordinate-canonicalization.test.mjs', because: 'a threshold below the measured nine-decimal tail leaves the real 1/240 noise ' + 'population intact while nominal exact nodes continue to pass (#291)', patches: [{ file: 'src/coordinate-canonicalization.ts', find: 'export const LATTICE_NOISE_STEPS = 1e-4;', replace: 'export const LATTICE_NOISE_STEPS = 1e-9;', }], }, { id: 'lattice-noise-threshold-too-large', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="all 4801 lattice nodes" ' + 'test/coordinate-canonicalization.test.mjs', because: 'a broad threshold silently attracts authored off-grid geometry instead of only ' + 'removing the measured storage tail (#291)', patches: [{ file: 'src/coordinate-canonicalization.ts', find: 'export const LATTICE_NOISE_STEPS = 1e-4;', replace: 'export const LATTICE_NOISE_STEPS = 0.5;', }], }, { id: 'lattice-layout-allowlist-omitted', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="scalar\\+lattice fixture contract" ' + 'test/coordinate-canonicalization.test.mjs', because: 'config-only canonicalization lets marker and room-label writes recreate the same ' + 'noise through the independent layout Store (#291)', patches: [{ file: 'src/coordinate-canonicalization.ts', find: ' for (const value of Object.values(root)) {\n' + ' const item = record(value);\n' + " if (item) latticeFields(item, ['x', 'y']);\n" + ' }\n return result;', replace: ' // mutant: omit all layout x/y values\n return result;', }], }, { id: 'lattice-unknown-fields-recursive', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="scalar\\+lattice fixture contract" ' + 'test/coordinate-canonicalization.test.mjs', because: 'recursively rounding future, physical and calibration numbers corrupts data which ' + 'is deliberately outside the persisted coordinate allow-list (#291)', patches: [{ file: 'src/coordinate-canonicalization.ts', find: ' }\n return value;\n}\n\n/** Existing scalar contract', // `canonicalizeNumber` объявлен как `unknown`, а функция возвращает `T`: // без приведения мутант падал на компиляции, а не на тесте (#569). replace: ' }\n return canonicalizeNumber(value) as T;\n}\n\n/** Existing scalar contract', }], }, { id: 'import-path-bypasses-schema', guard: 'node scripts/backend-test-guard.mjs import_document_canonicalizes_external_coordinates', because: 'an externally assembled backup is an independent source of noisy geometry and ' + 'must enter preview and storage through the same canonical schema as the live card', patches: [{ file: 'custom_components/houseplan/import_export.py', find: ' config = CONFIG_SCHEMA(config_candidate)', replace: ' config = config_candidate', }], }, { id: 'wall-component-failure-kills-primary', guard: 'node demo/smoke_wall_union_isolation.mjs', because: 'a local shell/extra merge failure must remain a render-safe component set instead ' + 'of restoring the all-or-nothing blank wall layer from #278', patches: [{ file: 'src/wall-thickness.ts', find: " status: degradedExtraCount || degradedCoreCount ? 'degraded-extra' : 'ok',", replace: " status: degradedExtraCount || degradedCoreCount ? 'failed-core' : 'ok',", }], }, { id: 'wall-isolated-extra-discarded', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="isolates one failed independent-body" ' + 'test/wall-thickness.test.mjs', because: 'keeping only the primary makes the floor look mostly repaired while silently ' + 'dropping the exact independent body whose union failed (#278)', patches: [{ file: 'src/wall-thickness.ts', find: ' isolated.push({ id: `extra-${index}`, geom: standalone });', replace: ' // mutant: discard the valid isolated body', }], }, { id: 'strict-wall-barrier-accepts-degraded', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="strict one-space barrier" ' + 'test/plan-geometry-preflight.test.mjs', because: 'render-safe degradation is read compatibility, never permission to persist another ' + 'geometry mutation over the unsafe candidate (#278)', patches: [{ file: 'src/plan-geometry-preflight.ts', find: " if (united.status === 'degraded-extra') {", // Ложь в рантайме, но не статическая: в мёртвой ветке TypeScript теряет // сужение `united` из `if (united == null) continue;` выше (#569). replace: " if (String(united.status) === 'mutant-never-degraded-extra') {", }], }, { id: 'wall-thickness-writer-bypasses-common-barrier', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="production source routes physical writers" ' + 'test/wall-union-isolation.test.mjs', because: 'one direct physical writer is enough to recreate the corrupt persisted geometry ' + 'even when Resize and the other editors use the shared transaction boundary (#278)', patches: [{ file: 'src/houseplan-card.ts', find: " if (this._commitPhysicalGeometry(this._t('history.wall_thickness'), before))", replace: " if ((this._recordGeometry(this._t('history.wall_thickness'), before), true))", }, { // #313: the independent-masonry writer is a second thickness commit // point — bypassing the barrier there recreates the same #278 hole. file: 'src/houseplan-card.ts', find: " const committed = this._commitPhysicalGeometry(\n this._t('history.wall_thickness'), before,\n );", replace: " const committed = (this._recordGeometry(this._t('history.wall_thickness'), before), true);", }], }, { id: 'model-invariants-bypasses-production-geometry', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="Optimize and model-invariants" ' + 'test/wall-union-isolation.test.mjs', because: 'the CLI must fail on the same degraded result as Optimize instead of returning the ' + 'pre-#278 false green for a visually broken export', patches: [{ file: 'scripts/model-invariants.mjs', find: ' return result.failures.map((failure, index) => ({', replace: ' return [].map((failure, index) => ({', }], }, { id: 'optimize-preflight-bypassed', // #642: диалог вынесен в свой модуль, гвард — его юнит. guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="red preflight is a hard write barrier" ' + 'test/optimize-plans-dialog.test.mjs', because: 'a red preview must remain a hard write barrier even if a caller invokes the ' + 'private Apply method directly instead of clicking the deliberately absent button (#199)', patches: [{ file: 'src/optimize-plans-dialog.ts', find: ' if (!d || d.busy || !this.port.config() || !d.changed || !d.preflight?.ok) return;', // `!d.preflight` остаётся ради сужения типа: иначе мутант не компилируется // и ловится компилятором, а не тестом. replace: ' if (!d || d.busy || !this.port.config() || !d.changed || !d.preflight) return;', }], }, { id: 'optimize-preflight-active-space-only', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="one failed space blocks" ' + 'test/plan-geometry-preflight.test.mjs', because: 'Optimize is one whole-plan transaction, so a broken non-active floor must block ' + 'the operation just as decisively as the first floor in config order (#199)', patches: [{ file: 'src/plan-geometry-preflight.ts', find: ' const rawSpaces = Array.isArray(config?.spaces) ? config.spaces : [];', replace: ' const rawSpaces = Array.isArray(config?.spaces) ? config.spaces.slice(0, 1) : [];', }], }, { id: 'optimize-preflight-accepts-null', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="null, exceptions and floor failure" ' + 'test/plan-geometry-preflight.test.mjs', because: 'wallBodiesGeometry null is a structural boolean failure, not an empty successful ' + 'wall set that may be hidden behind a later floor fallback (#199)', patches: [{ file: 'src/plan-geometry-preflight.ts', find: ' if (united == null) {', replace: ' if (false && united == null) {', }], }, { id: 'optimize-preflight-renders-apply-on-failure', // #642: разметка диалога — исполнением в юните модуля. guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="failed preflight names each reason and offers no Apply" ' + 'test/optimize-plans-dialog.test.mjs', because: 'the failure state is not a dismissible warning: rendering Apply invites a person ' + 'to treat an unsafe whole-plan candidate as an accepted risk (#199)', patches: [{ file: 'src/optimize-plans-dialog.ts', find: ' ${!d.changed || !d.preflight?.ok ? nothing : html`', replace: ' ${!d.changed ? nothing : html`', }], }, { id: 'union-failure-kills-space', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="malformed room|fallback unions overlapping" ' + 'test/physical-geometry.test.mjs', because: 'one room rejected by polyclip must be skipped locally while every healthy floor ' + 'fragment remains clipped and visible rather than failing the whole space dark', patches: [{ file: 'src/physical-geometry.ts', find: ' return intersectionPathsByBound(base, bounds, options);', replace: ' return [];', }], }, { id: 'union-failure-silent', guard: 'node demo/smoke_glow_geometry_resilience.mjs', because: 'a residual per-room geometry fallback must leave one redacted, deduplicated ' + 'space/room diagnostic instead of silently looking like a broken Glow setting', patches: [{ file: 'src/glow-scene.ts', find: ' console.warn(\n' + ' `HOUSEPLAN GLOW GEOMETRY FALLBACK: #218, space ${spaceId}, room ${roomId}, phase ${phase}`,\n' + ' );', replace: ' if (false) console.warn(\n' + ' `HOUSEPLAN GLOW GEOMETRY FALLBACK: #218, space ${spaceId}, room ${roomId}, phase ${phase}`,\n' + ' );', }], }, { id: 'glow-fail-dark-weakened', guard: 'node demo/smoke_glow_fail_dark.mjs', because: 'resilient floor clipping must not revive a source embedded in opaque masonry; ' + 'the existing source guard remains a release-blocking fail-dark boundary', patches: [{ file: 'src/glow-scene.ts', find: ' return pointInOpaquePlanBody(\n' + ' [source.x, source.y], scene.masonryGeometry, scene.opaqueBodies,\n' + ' );', replace: ' return false && pointInOpaquePlanBody(\n' + ' [source.x, source.y], scene.masonryGeometry, scene.opaqueBodies,\n' + ' );', }], }, { id: 'near-axis-threshold-weakened', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="#290 near-axis boundary" test/near-axis.test.mjs', because: 'the measured 316x1 wall must stay inside the one shared 0.25 degree drafting ' + 'tolerance while 316x2 and a true diagonal remain outside (#290)', patches: [{ file: 'src/near-axis.ts', find: 'export const NEAR_AXIS_MAX_DEGREES = 0.25;', replace: 'export const NEAR_AXIS_MAX_DEGREES = 0.1;', }], }, { id: 'near-axis-inclusive-boundary-disabled', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="#290 near-axis boundary" test/near-axis.test.mjs', because: 'the product contract includes an edge exactly on the 0.25 degree boundary (#290)', patches: [{ file: 'src/near-axis.ts', find: ' if (minor / major > NEAR_AXIS_MAX_SLOPE) return null;', replace: ' if (minor / major >= NEAR_AXIS_MAX_SLOPE) return null;', }], }, { id: 'near-axis-authoring-snap-bypassed', guard: 'node demo/smoke_plan_drawing_repairs.mjs', because: 'the production Walls hover and click must persist the exact same straight endpoint ' + 'instead of merely repairing old data through Optimize (#290)', patches: [{ file: 'src/houseplan-card.ts', find: ' const point = anchor ? snapNearAxisEndpoint(anchor, snapped) : snapped;', replace: ' const point = snapped;', }], }, { id: 'near-axis-shared-owner-repair-partial', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="#290 repairs a duplicated" test/near-axis.test.mjs', because: 'a shared physical wall must move every coincident room-owner copy atomically (#290)', patches: [{ file: 'src/near-axis.ts', find: 'const replaceRoomPoints = (rooms: any[], move: EndpointMove): any[] => rooms.map((room) => {', replace: 'const replaceRoomPoints = (rooms: any[], move: EndpointMove): any[] => rooms.map((room, index) => {\n' + ' if (index > 0) return room;', }], }, { id: 'near-axis-shared-owner-double-counted', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="#290 repairs a duplicated" test/near-axis.test.mjs', because: 'coincident room-owner copies are one physical wall in the Optimize report (#290)', patches: [{ file: 'src/near-axis.ts', find: ' const key = segmentKey(a, b);', replace: ' const key = `${segmentKey(a, b)}:${candidates.size}`;', }], }, { id: 'near-axis-optimize-confirmation-bypassed', // #642: открытие — метод модуля диалога; гвард — его юнит. guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="preview never writes" ' + 'test/optimize-plans-dialog.test.mjs', because: 'opening the Optimize preview or cancelling it must never persist a lossy repair (#290)', patches: [{ file: 'src/optimize-plans-dialog.ts', find: ' public open(): void { this.preview(false); }', replace: ' public open(): void {\n' + ' this.preview(false);\n' + ' void this.run();\n' + ' }', }], }, { id: 'optimizer-micro-interval-cleanup-disabled', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="isolated thickness micro-interval" ' + 'test/plan-optimizer.test.mjs', because: 'explicit Optimize must remove only the proven sub-half-step 22→15→22 ' + 'artefact instead of continuing to call the lossy plan canonical', patches: [{ file: 'src/plan-optimizer.ts', find: ' if (!walls?.length) return [];\n const scale = coordScale > 0 ? coordScale : 1;\n' + ' const eps = Math.max(pitch * scale * 0.02, 1e-9);', // Возврат под рантайм-условием, а не безусловный: после безусловного // `return` остаток функции становится недостижимым, а в недостижимом коде // TypeScript теряет сужение `profile` и мутант падает на компиляции (#568). replace: ' if (!walls?.length) return [];\n if (walls.length >= 0) return walls.slice();\n' + ' const scale = coordScale > 0 ? coordScale : 1;\n' + ' const eps = Math.max(pitch * scale * 0.02, 1e-9);', }], }, { id: 'wall-compaction-owner-role-bypassed', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="#299" test/wall-thickness.test.mjs', because: 'equal centimetres must not merge shared(A,B) with outer(A) or shared(A,C); ' + 'dropping the owner signature recreates the mixed-role wall record from #299', patches: [{ file: 'src/wall-thickness.ts', find: ' if (pr.kinds[next] === null || pr.cms[next] !== cm\n' + ' || ownerSignatureFor(nextKey) !== ownerSignature) break;', replace: ' if (pr.kinds[next] === null || pr.cms[next] !== cm) break;', }], }, { id: 'optimizer-single-topology-island-blocked', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 273 Optimize" ' + 'test/plan-optimizer.test.mjs', because: 'a proven 22→15→22 island beside exactly one room T-node must not survive ' + 'merely because the old #198 guard classified both endpoints identically', patches: [{ file: 'src/plan-optimizer.ts', find: ' if (isNode(a, roomNodes) && isNode(b, roomNodes)) continue;', replace: ' if (isNode(a, roomNodes) || isNode(b, roomNodes)) continue;', }], }, { id: 'optimizer-coincident-opening-rehost-disabled', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 276 reconciles" ' + 'test/coincident-partitions.test.mjs', because: 'removing the redundant independent wall without materialising its hosted opening ' + 'would leave a dangling host and make the door disappear after explicit Optimize (#276)', patches: [{ file: 'src/coincident-partitions.ts', find: ' const nextOpenings = openings.map((opening) => openingReplacement.get(opening.id) || opening);', replace: ' const nextOpenings = openings.map((opening) => opening);', }], }, { id: 'optimizer-coincident-residual-dropped', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="covered middle" ' + 'test/optimize-hidden-obstacles.test.mjs', because: 'piecewise Optimize may absorb only the exactly covered middle of an independent ' + 'wall and must preserve both free residual spans with stable identifiers (#296)', patches: [{ file: 'src/coincident-partitions.ts', find: ' const residualRuns = runs.filter((run) => !run.safe);', replace: ' const residualRuns: PieceRun[] = [];', }], }, { id: 'optimizer-coincident-max-thickness-lost', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="max thickness independently" ' + 'test/optimize-hidden-obstacles.test.mjs', because: 'each absorbed piece must retain the thicker of room masonry and the hidden ' + 'partition instead of silently thinning an authored wall (#296)', patches: [{ file: 'src/coincident-partitions.ts', find: ' const finalCm = proofOk ? Math.max(roomCm, source.cm) : source.cm;', replace: ' const finalCm = proofOk ? roomCm : source.cm;', }], }, { id: 'optimizer-backend-trusts-frontend-delta', guard: 'node scripts/backend-test-guard.mjs ' + 'backend_proves_full_partition_delta_without_openings tests_backend/test_validation.py', because: 'the backend must reconstruct every removed partition atom independently; ' + 'trusting the frontend candidate lets a no-opening partition disappear without masonry (#296)', patches: [{ file: 'custom_components/houseplan/validation.py', find: 'def _safe_optimize_partition_delta(space: dict, old_partition: dict) -> bool:\n' + ' """Independently prove every removed atom of one old partition axis."""\n' + ' if not _known_optimize_partition(old_partition):', replace: 'def _safe_optimize_partition_delta(space: dict, old_partition: dict) -> bool:\n' + ' """Independently prove every removed atom of one old partition axis."""\n' + ' return True\n' + ' if not _known_optimize_partition(old_partition):', }], }, { id: 'atomic-child-thickness-parent-fallback', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="exact parent|atomic solid children" ' + 'test/wall-thickness.test.mjs test/open-spans.test.mjs', because: 'closing a fully virtual stretch beside an atomised exact parent run must inherit ' + 'the real neighbouring thickness instead of silently writing the 15 cm default', patches: [{ file: 'src/wall-thickness.ts', find: ' const exact = exactCoveringWall(walls, a, b, pitch, coordScale);', replace: ' const exact = false ? exactCoveringWall(walls, a, b, pitch, coordScale) : null;', }], }, { id: 'device-position-cancel-routed-to-commit', guard: 'node demo/smoke_device_position_history.mjs', because: 'pointer cancellation and lost capture must restore the uncommitted device preview ' + 'without creating a position command or writing layout (#74)', patches: [{ file: 'src/houseplan-card.ts', find: '@pointercancel=${(e: PointerEvent) => this._pointerCancel(e, d)}', replace: '@pointercancel=${(e: PointerEvent) => this._pointerUp(e, d)}', }], }, { id: 'stale-space-position-guard-removed', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="stable space ids" test/optional-space-model-contract.test.mjs', because: 'position persistence with a stable space id must fail closed before layout, dirty-set ' + 'and websocket side effects when that space has been deleted or renamed', patches: [{ file: 'src/houseplan-card.ts', find: ' if (!this._spaceModelById(d.space)) return;\n this._layout = applyDevicePlacement(', replace: ' this._layout = applyDevicePlacement(', }], }, { id: 'fixed-floor-transition-guard-bypassed', guard: 'node demo/smoke_fixed_floor.mjs', because: 'a fixed card must reject tabs and every internal active-space mutation instead of ' + 'showing another floor after the initial resolver selected the configured one', patches: [{ file: 'src/houseplan-card.ts', find: ' if (authority || !this._hasFixedFloor) return true;', replace: ' if (authority || this._hasFixedFloor) return true;', }], }, { id: 'empty-space-cleanup-disabled', guard: 'node demo/smoke_optional_space_model.mjs', because: 'удаление последнего пространства обязано завершать жесты, редакторы и отложенную запись; ' + 'смок дважды переводит живую карточку в пустой план и проверяет реальный lifecycle cleanup', patches: [{ file: 'src/houseplan-card.ts', find: 'if (this._emptySpaceStateActive) return;', replace: 'if (empty) return;', }], }, { id: 'continuity-long-resume-noop', guard: 'node demo/smoke_visual_continuity.mjs', because: 'смок обязан доказывать, что long-resume СРАБОТАЛ (токен ушёл вперёд), ' + 'а не что ничего не спрятали: в beta.7 он не заметил удаления всего механизма', patches: [{ file: 'src/visual-continuity.ts', find: "if (!signal.long && (signal.kind === 'visible' || signal.kind === 'pageshow')) {", replace: "if ((signal.kind === 'visible' || signal.kind === 'pageshow')) {", }], }, { id: 'opening-cut-degenerate', guard: 'node demo/smoke_glow.mjs', because: 'проём, выродившийся в точку, остаётся кладкой — свет перестаёт ' + 'проходить через дверь; смок обязан это увидеть по освещённому полу за проёмом', patches: [{ file: 'src/glow-scene.ts', find: ' outlineCuts.push([\n' + ' opening.x - dx, opening.y - dy,\n' + ' opening.x + dx, opening.y + dy,\n' + ' ]);', replace: ' outlineCuts.push([\n' + ' opening.x, opening.y,\n' + ' opening.x, opening.y,\n' + ' ]);', }], }, { id: 'registryless-opening-requires-registry-row', guard: 'node demo/smoke_registryless_opening.mjs', because: 'возврат требования Entity Registry row снова делает выбранную YAML-сущность ' + 'невидимой в painted frame; smoke обязан доказать contact, lock и frozen-snapshot parity', patches: [{ file: 'src/ha-binding-status.ts', find: ' return !!entityId\n && !!projectedHass?.states?.[entityId];', replace: ' return !!entityId\n && !!projectedHass?.entities?.[entityId]\n' + ' && !!projectedHass?.states?.[entityId];', }], }, { id: 'column-shadow-removed', guard: 'node demo/smoke_glow.mjs', because: 'физические тела выпали из окклюдеров — колонна перестаёт отбрасывать ' + 'тень; исторически смок теней был зелёным, пока тени физически не рисовались', patches: [{ file: 'src/glow-scene.ts', // Physical bodies now enter the one canonical scene shared by both // cards. Removing them here must drop column shadows in the smoke. find: ' const opaqueBodies = input.physicalBodies(partitionCuts, cacheKey);', replace: ' const opaqueBodies: number[][][] = [];', }], }, { id: 'decor-restored-below-room-fills', guard: 'node demo/smoke_decor_layer_order.mjs', because: 'moving decor back before room fills reproduces #231: opaque custom floors, room ' + 'hover and Glow base erase stored decor even though the decor group still exists in the DOM; ' + 'the raster probes must catch the visual regression, including the filled opening tunnel', patches: [{ file: 'src/houseplan-card.ts', find: " ${''/* Decor is one composition layer above every floor treatment\n" + ' (room fill/hover, opening tunnels and Glow base) and below\n' + ' live lighting, physical plan geometry and devices. Keep\n' + ' hide_decor visual-only: the decor editor must always paint\n' + ' stored shapes so they remain editable. */}\n' + " ${disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer(undefined, view)}\n", replace: '', }, { file: 'src/houseplan-card.ts', find: ' ${(() => {\n' + ' // audit L1: hoisted out of the per-room map — these depend on the', replace: " ${disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer(undefined, view)}\n" + ' ${(() => {\n' + ' // audit L1: hoisted out of the per-room map — these depend on the', }], }, { id: 'feather-20px', guard: 'node demo/smoke_glow.mjs', because: 'растушёвка 20 px вместо 2 размывает границу света на полкомнаты; ' + 'смок читает data-feather-px и обязан отвергнуть значение больше 3', patches: [{ file: 'src/glow-scene.ts', find: 'export const GLOW_EDGE_FEATHER_PX = 2;', replace: 'export const GLOW_EDGE_FEATHER_PX = 20;', }], }, { id: 'barrier-cache-never-invalidated', guard: 'node demo/smoke_zero_walls.mjs', because: 'кэш барьеров, который не инвалидируется по содержимому, — это свет ' + 'сквозь стену, которая уже существует; смок переключает пунктирную нулевую ' + 'стену в сплошную и обязан увидеть смену освещённости соседней комнаты', patches: [{ file: 'src/houseplan-card.ts', find: ' const cacheKey = `${space.id}|${revision.fingerprint}`;', replace: ' const cacheKey = space.id; // mutant: ignore changed geometry', }], }, { id: 'golden-lamp-out-of-reach', guard: 'node demo/golden/run.mjs --mode=capture --scenario=lighting-opaque-glow-two-doorways-dark', because: 'сцена заведена как защита дверного света (#71) и однажды уже была ' + 'пустой: лампа стояла так, что пятно не доходило до стены. Уведённая лампа ' + 'обязана ронять семантический ассерт warmPixelRegion, а не только пиксельный дифф. ' + 'Режим capture, а не verify: политика запрещает verify по одной сцене ' + '(demo/golden/policy.mjs) — частичный verify это лазейка «чтобы CI позеленел». ' + 'На capture семантический провал даёт status error, и goldenRunFailed ' + 'краснеет в любом режиме — то есть проверка сохраняется полностью', // Мутируется фикстура сцены, не продуктовый код: пустота сцены — свойство // фикстуры. Пересборка бандла всё равно нужна, путь тот же. patches: [{ file: 'demo/golden/matrix.mjs', find: "layoutOverrides: { 'golden-light-one': { s: 'golden-lighting', x: 0.40, y: 0.48 } },", replace: "layoutOverrides: { 'golden-light-one': { s: 'golden-lighting', x: 0.06, y: 0.90 } },", }], }, { id: 'golden-filled-tunnel-removed', guard: 'node demo/golden/run.mjs --mode=capture --scenario=openings-filled-tunnel-dark', because: 'сцена защищает непрерывную room-coloured заливку толстого тоннеля (#81): ' + 'если fixture перестаёт рисовать заливку, semantic tunnelContinuity обязан упасть, ' + 'а не принять пустой проём как корректный golden', // Ломаем только заявленное условие fixture. Capture сохраняет полный // semantic gate даже для одной сцены и не зависит от pixel baseline. patches: [{ file: 'demo/golden/matrix.mjs', find: "{ id: 'openings-filled-tunnel-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',\n fillMode: 'custom', customFill: { c: '#66717c', a: 0.55 }, glowEnabled: false,", replace: "{ id: 'openings-filled-tunnel-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',\n fillMode: 'none', customFill: { c: '#66717c', a: 0.55 }, glowEnabled: false,", }], }, { id: 'sun-ray-outer-jamb-occlusion-ignored', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="#580:" test/sun.test.mjs', because: 'если комнатную часть снова клиповать только полом, косой внешний луч ' + 'проходит через сплошной откос; unit-защита #580 обязана увидеть лишний интервал', patches: [{ file: 'src/sun.ts', find: ' ...intersectRings(quad, rayQuad(innerA, innerB, away, len), clipPoly),', replace: ' ...clipToRoom(quad, clipPoly), // mutant: skip the inner aperture', }], }, { id: 'sun-ray-outer-occluded-rim-source-ignored', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="#580: an oblique" test/sun.test.mjs', because: 'после затенения новая боковая граница начинается на внутреннем откосе; ' + 'unit-защита #580 обязана поймать пропавшую чёткую обводку', patches: [{ file: 'src/sun.ts', find: ' rimSources = [a, b, innerA, innerB];', replace: ' rimSources = [a, b]; // mutant: ignore the occluded beam sides', }], }, { id: 'sun-ray-origin-outer-ignored', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="#577: outer rays" test/sun.test.mjs', because: 'если внешний режим снова использует внутреннюю грань, геометрия остаётся ' + 'визуально прежней; unit-защита #577 обязана увидеть обе фасадные точки и тоннель', patches: [{ file: 'src/sun.ts', find: " const side = origin === 'outer' ? -1 : 1;", replace: ' const side = 1; // mutant: ignore outer source face', }], }, { id: 'sun-ray-origin-cache-ignored', guard: 'node demo/smoke_sun.mjs', because: 'переключение в открытом диалоге не меняет config epoch; без origin в ключе ' + 'пользователь до следующей записи видит закэшированную старую геометрию (#577)', patches: [{ file: 'src/houseplan-card.ts', find: '`${space.id}|${sun.azimuth}|${sun.elevation}|${north}|${origin}|${this._cfgEpoch}`', replace: '`${space.id}|${sun.azimuth}|${sun.elevation}|${north}|${this._cfgEpoch}`', }], }, { id: 'sun-ray-origin-save-forced-inner', guard: 'node demo/smoke_sun.mjs', because: 'селектор без сохранения — ложная функция; браузерный round-trip обязан ' + 'увидеть outer после Save и при повторном открытии общих настроек (#577)', patches: [{ file: 'src/houseplan-editor-runtime.ts', find: ' settings.sun_ray_origin = d.sunRayOrigin;', replace: " settings.sun_ray_origin = 'inner'; // mutant: discard the selected face", }], }, { id: 'sun-ray-origin-backend-allows-any-string', guard: 'node scripts/backend-test-guard.mjs test_sun_settings_global tests_backend/test_validation.py', because: 'backend обязан отвергать неизвестный enum, иначе сохранённая опечатка расходится ' + 'с UI и молча читается как inner; строгий validation-тест #577 должен покраснеть', patches: [{ file: 'custom_components/houseplan/validation.py', find: '_SUN_RAY_ORIGIN = vol.In(["inner", "outer"])', replace: '_SUN_RAY_ORIGIN = str # mutant: accept any string', }], }, { id: 'sun-ray-origin-support-leaks-invalid', guard: 'node scripts/backend-test-guard.mjs ' + 'test_projection_helpers_fail_closed_on_malformed_shapes tests_backend/test_support_package.py', because: 'support package переносит только безопасный известный enum; неизвестная строка ' + 'не должна попадать в диагностический архив (#577)', patches: [{ file: 'custom_components/houseplan/support_package.py', find: ' if sun_ray_origin in ("inner", "outer"):', replace: ' if isinstance(sun_ray_origin, str): # mutant: leak unknown strings', }], }, { id: 'sun-north-subtraction-restored', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="planSunAngle" test/sun.test.mjs', because: 'возврат старого azimuth - northDeg снова зеркалит направление при ' + 'ненулевом севере; табличный AC-01 обязан покраснеть на 90+90 и wrap-кейсах', patches: [{ file: 'src/sun.ts', find: 'return norm360(azimuth + northDeg);', replace: 'return norm360(azimuth - northDeg);', }], }, { id: 'sun-to-sun-vector-inverted', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="north right plus east sun" test/sun.test.mjs', because: 'формула угла остаётся правильной, но инверсия toSun выбирает окно с ' + 'противоположной стороны; направленный AC-03 обязан отличить окно от направления луча', patches: [{ file: 'src/sun.ts', find: ' const toSun = sunDirOnPlan(azimuth, northDeg);\n const away: [number, number] = [-toSun[0], -toSun[1]];', replace: ' const direction = sunDirOnPlan(azimuth, northDeg);\n const toSun: [number, number] = [-direction[0], -direction[1]];\n const away: [number, number] = [-toSun[0], -toSun[1]];', }], }, { id: 'sun-golden-north-neutralized', guard: 'node --test --test-name-pattern="sun-ray golden" test/golden-matrix.test.mjs', because: 'нулевой north_deg снова делает golden нечувствительным к знаку композиции; ' + 'структурный тест обязан требовать ненулевой север и асимметричный азимут', patches: [{ file: 'demo/golden/matrix.mjs', find: " { id: 'lighting-sun-window-state-only-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',\n" + ' // The golden screenshot is backed by a second, sun-layer-hidden capture.\n' + ' // A real painted ray must account for enough changed pixels; DOM-only\n' + ' // presence or an accidentally accepted empty baseline is not sufficient.\n' + ' glowEnabled: false, allLightsOff: true, northDeg: 90,', replace: " { id: 'lighting-sun-window-state-only-dark', fixture: 'visual', space: 'golden-lighting', mode: 'view',\n" + ' // The golden screenshot is backed by a second, sun-layer-hidden capture.\n' + ' // A real painted ray must account for enough changed pixels; DOM-only\n' + ' // presence or an accidentally accepted empty baseline is not sufficient.\n' + ' glowEnabled: false, allLightsOff: true, northDeg: 0,', }], }, { id: 'plan-only-room-area-restored', guard: 'node scripts/backend-test-guard.mjs plan_only_export_projects', because: 'возврат room.area нарушает основное обещание чистого шаблона; ' + 'projection/roundtrip тест обязан увидеть HA Area даже при нулевых markers', patches: [{ file: 'custom_components/houseplan/import_export.py', find: '_ROOM_PLAN_FIELDS = ("id", "name", "open_to", "x", "y", "w", "h", "poly", "wall_ids")', replace: '_ROOM_PLAN_FIELDS = ("id", "name", "area", "open_to", "x", "y", "w", "h", "poly", "wall_ids")', }], }, { id: 'plan-only-projector-bypassed', guard: 'node scripts/backend-test-guard.mjs plan_only_export_projects', because: 'игнорирование request-флага возвращает markers и device layout; ' + 'полный representative export обязан доказать, что true включает lossy projector', patches: [{ file: 'custom_components/houseplan/import_export.py', find: ' if plan_only:\n projected_space = _project_plan_only_space(space)', replace: ' if False and plan_only:\n projected_space = _project_plan_only_space(space)', }], }, { id: 'ordinary-export-plan-only-false-emitted', guard: 'node scripts/backend-test-guard.mjs ordinary_space_export_is_unchanged', because: 'аддитивное поле не должно менять обычный space export даже значением false; ' + 'fixed regression обязан сохранить прежнюю структуру документа', patches: [{ file: 'custom_components/houseplan/import_export.py', find: '**({"plan_only": True} if plan_only else {}),', replace: '**({"plan_only": plan_only}),', }], }, { id: 'space-import-keeps-incoming-vacuum-route-space', guard: 'node scripts/backend-test-guard.mjs issue_611_incoming_route_follows_fresh_space', because: 'a copied vacuum route must follow the fresh space id on both foreign and ' + 'same-instance imports instead of failing validation or silently targeting the source floor', patches: [{ file: 'custom_components/houseplan/import_export.py', find: ' route["space"] = new_space_id\n' + ' _report_remap(', replace: ' route["space"] = old_space_id # mutant: keep source floor\n' + ' _report_remap(', }], }, { id: 'space-import-keeps-target-vacuum-route-space', guard: 'node scripts/backend-test-guard.mjs issue_611_target_route_follows_safe_space_repair', because: 'the second-pass target repair must apply its proven exact/lineage space mapping to ' + 'vacuum routes as well as marker.space', patches: [{ file: 'custom_components/houseplan/import_export.py', find: ' route["space"] = mapped\n', replace: ' route["space"] = route.get("space") # mutant: ignore mapping\n', }], }, { id: 'plan-only-revalidate-flag-dropped', guard: 'node scripts/backend-test-guard.mjs plan_only_export_projects', because: 'revalidate не имеет права превращать plan-only preview в обычный space preview; ' + 'roundtrip test проверяет сохранение флага после обновления revisions', patches: [{ file: 'custom_components/houseplan/import_export.py', find: ' "plan_only": _transfer_plan_only(document),\n "counts":', replace: ' "plan_only": False,\n "counts":', }], }, { id: 'plan-only-forged-file-trusted', guard: 'node scripts/backend-test-guard.mjs forged_plan_only_privacy_claim', because: 'флаг файла не является доказательством приватности; negative parser matrix ' + 'обязана покраснеть, если schema-aware проверка больше не вызывается', patches: [{ file: 'custom_components/houseplan/import_export.py', find: ' if plan_only:\n _validate_plan_only_document(document, config, layout, placement)', replace: ' if False and plan_only:\n _validate_plan_only_document(document, config, layout, placement)', }], }, { id: 'plan-only-preview-label-hidden', guard: 'node demo/smoke_backup_transfer.mjs', because: 'валидный plan-only файл без явной строки в preview выглядит обычной копией; ' + 'browser smoke обязан проверять пользовательский статус, а не только backend-флаг', patches: [{ file: 'src/houseplan-card.ts', find: "${p.plan_only ? html`${this._t('backup.plan_only_preview')}` : nothing}", replace: "${false ? html`${this._t('backup.plan_only_preview')}` : nothing}", }], }, { id: 'passage-visible-geometry-door-fallback', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/opening-symbol.test.mjs', because: 'если явную пустую ветку passage убрать, общий fallback рисует дверную створку; ' + 'renderer test обязан требовать буквально пустую видимую геометрию', patches: [{ file: 'src/render/opening-symbol.ts', find: " if (spec.type === 'passage') return svg``;", replace: " if (false && spec.type === 'passage') return svg``;", }], }, { id: 'passage-light-classifier-removed', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/light-visibility.test.mjs', because: 'потеря passage из явного allowlist снова делает внутренний открытый проём ' + 'непрозрачным; тест одновременно сохраняет fail-dark для неизвестных типов', patches: [{ file: 'src/logic.ts', find: " return type === 'door' || type === 'gate' || type === 'passage';", replace: " return type === 'door' || type === 'gate';", }], }, { id: 'passage-import-validator-bypassed', guard: 'node scripts/backend-test-guard.mjs invalid_passage_import', because: 'импорт является недоверенным новым content и не имеет broken-read исключения; ' + 'forged contact/lock обязан быть отклонён до создания preview', patches: [{ file: 'custom_components/houseplan/import_export.py', find: ' validate_opening_passages(incoming_config, validate_all=True)', replace: ' if False:\n validate_opening_passages(incoming_config, validate_all=True)', }], }, { id: 'passage-static-door-cut-leak', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/space-geometry.test.mjs', because: 'Static обязан менять masonry только для нового passage; добавление обычной двери ' + 'в cuts сломает сохранённую совместимость старых планов', patches: [{ file: 'src/space-geometry.ts', find: " if (opening?.type !== 'passage') continue;", replace: " if (opening?.type !== 'passage' && opening?.type !== 'door') continue;", }], }, { id: 'passage-iso-door-fallback', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/iso-openings.test.mjs', because: 'без zero-leaf branch скрытая изометрия превращает passage в дверь; ' + 'basis test обязан ловить любое появление панели независимо от live state', patches: [{ file: 'src/iso-openings.ts', find: " if (input.type === 'passage') {", replace: " if (false && input.type === 'passage') {", }], }, { id: 'entity-marker-kept-in-parent-device', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="entity marker owns its entity|partial auto parent" ' + 'test/devices.test.mjs', because: 'an explicitly placed entity must be removed from the residual automatic parent ' + 'so state, action and light projection cannot render the same HA channel twice', patches: [{ file: 'src/devices.ts', find: ' !owned.has(entityId) && !hass?.entities?.[entityId]?.hidden),', replace: ' !hass?.entities?.[entityId]?.hidden),', }], }, { id: 'entity-marker-parent-seeded', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="seedHiddenBindings: entity ownership" ' + 'test/devices.test.mjs', because: 'the first-run seeder must share residual ownership with buildDevices or it can ' + 'materialise the removed automatic duplicate as a permanent hidden device marker', patches: [{ file: 'src/devices.ts', find: ' const entsBy = entitiesByDevice(h);\n' + ' const ownership = entityMarkerOwnership(markers, fullHass);\n' + ' const marked = new Set(markers.map((m) => m.binding));', replace: ' const entsBy = entitiesByDevice(h);\n' + ' const ownership = entityMarkerOwnership([], fullHass);\n' + ' const marked = new Set(markers.map((m) => m.binding));', }], }, { id: 'manual-room-device-area-fallback', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="manual room without area.*device" test/devices.test.mjs', because: 'возврат старого device fallback снова даёт registry Area приоритет над явно ' + 'сохранённой комнатой без HA Area; AC1 обязан увидеть неверные area и space', patches: [{ file: 'src/devices.ts', find: ' const { area, space } = resolveExplicitMarkerPlacement(\n' + ' m, dev?.area_id, areaToSpace, firstSpaceId,\n' + ' );', replace: " const area = m.area || dev?.area_id || '';\n" + ' const space = (area && areaToSpace[area]) || m.space || firstSpaceId;', }], }, { id: 'manual-room-entity-branch-skipped', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="manual room without area.*entity" test/devices.test.mjs', because: 'исправление только device-ветки оставляет entity Area и Area родительского ' + 'устройства способными увести маркер из ручной комнаты; AC2 покрывает оба пути', patches: [{ file: 'src/devices.ts', find: ' const { area, space } = resolveExplicitMarkerPlacement(\n' + ' m, registryArea, areaToSpace, firstSpaceId,\n' + ' );', replace: " const area = m.area || registryArea || '';\n" + ' const space = (area && areaToSpace[area]) || m.space || firstSpaceId;', }], }, { id: 'area-null-without-room-id-hijacked', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="area null without room_id" test/devices.test.mjs', because: 'одно area:null не доказывает ручную комнату: расширенный предикат угоняет ' + 'legacy marker без room_id из registry Area; негативный AC3 обязан это поймать', patches: [{ file: 'src/devices.ts', find: " const manualRoomWithoutArea = typeof marker.room_id === 'string'\n" + ' && marker.room_id.length > 0\n' + ' && marker.area === null;', replace: ' const manualRoomWithoutArea = marker.area === null;', }], }, { id: 'reopened-room-from-registry-space', guard: 'node demo/smoke_subarea.mjs', because: 'room_id без правильного effective space создаёт отсутствующую option при ' + 'повторном открытии; browser smoke обязан восстановить точный garden#@room id', patches: [{ file: 'src/houseplan-card.ts', find: " ? d.space + '#@' + d.marker.room_id", replace: " ? this._space + '#@' + d.marker.room_id", }], }, { id: 'same-space-room-change-recenters', guard: 'node demo/smoke_subarea.mjs', because: 'редактирование комнаты внутри одного пространства не должно двигать уже ' + 'расставленный маркер; smoke сравнивает закреплённые координаты до и после Save', patches: [{ file: 'src/houseplan-card.ts', find: ' if (!replacingRemoved && prevPos && prevPos.s === targetSpaceId) {', replace: ' if (!replacingRemoved && prevPos && prevPos.s === targetSpaceId && !roomChanged) {', }], }, { id: 'hidden-room-names-full-svg-fallback', guard: 'node demo/smoke_hide_room_names.mjs', because: 'show_names:false must remove every permanent label from the full flat renderer; ' + 'the smoke must reject the legacy centred SVG substitute independently of compact and iso', patches: [{ file: 'src/houseplan-card.ts', find: ' ${glowLayerVisible ? this._renderGlowLayer(space, disp, view) : nothing}', replace: ' ${!space.bg && !disp.showNames && !this._markup ? svg`${space.rooms.map((room) => {\n' + ' const center = this._roomCenter(room);\n' + ' return svg`${room.name}`;\n' + ' })}` : nothing}\n' + ' ${glowLayerVisible ? this._renderGlowLayer(space, disp, view) : nothing}', }], }, { id: 'hidden-room-names-compact-svg-fallback', guard: 'node demo/smoke_hide_room_names.mjs', because: 'the compact card is an independent renderer and must not replace a disabled HTML ' + 'room card with the old SVG name even when the full card is correct', patches: [{ file: 'src/space-render.ts', find: ' spaceModels, defaultPositions, markerPos, labelPos, spaceFrame, iconCqw, NORM_W,', replace: ' spaceModels, roomCenter, defaultPositions, markerPos, labelPos, spaceFrame, iconCqw, NORM_W,', }, { file: 'src/space-render.ts', find: ' ${passageGlowTunnels}\n' + ' ${decorImages}\n' + ' ${stairShapes}\n' + ' ${glowPools}\n ${wallUnion', replace: ' ${passageGlowTunnels}\n' + ' ${!space.bg && !disp.showNames ? svg`${space.rooms.map((room) => {\n' + ' const center = roomCenter(room);\n' + ' return svg`${room.name}`;\n' + ' })}` : nothing}\n' + ' ${decorImages}\n' + ' ${stairShapes}\n' + ' ${glowPools}\n' + ' ${wallUnion', }], }, { id: 'hidden-room-names-iso-override', guard: 'node demo/smoke_hide_room_names.mjs', because: 'hidden isometric View must obey the same show_names:false contract instead of ' + 'forcing an HTML room card solely because the space has no backdrop', patches: [{ file: 'src/houseplan-card.ts', find: ' ${disp.showNames || this._markup\n', replace: ' ${disp.showNames || (iso && !space.bg) || this._markup\n', }], }, { id: 'plan-room-area-icon-hidden', guard: 'node demo/smoke_room_link.mjs', because: 'Plan обязан сохранять тот же состав name-row, что и View; возврат прежнего ' + 'условия снова убирает Area icon и сдвигает подпись относительно anchor', patches: [{ file: 'src/houseplan-card.ts', find: ' const showAreaLink = !!r.area;', replace: ' const showAreaLink = !this._markup && !!r.area;', }], }, { id: 'plan-room-area-icon-navigates', guard: 'node demo/smoke_room_link.mjs', because: 'Area icon в Plan является частью drag-зоны, а не ссылкой; включение View-handlers ' + 'обязано одновременно проявиться навигацией и невозможностью начать drag с иконки', patches: [{ file: 'src/houseplan-card.ts', find: ' const areaLinkInteractive = !this._markup;', replace: ' const areaLinkInteractive = true;', }], }, { id: 'space-create-hidden-display-override', guard: 'node demo/smoke_space_create_display_defaults.mjs', because: 'Save must persist the two booleans visibly chosen for a new hand-drawn space; ' + 'the production smoke saves and reopens a mixed pair so the old hidden true/true override cannot return', patches: [{ file: 'src/houseplan-card.ts', find: ' show_borders: d.showBorders,\n show_names: d.showNames,', replace: " show_borders: d.source === 'draw' && d.mode === 'create' ? true : d.showBorders,\n" + " show_names: d.source === 'draw' && d.mode === 'create' ? true : d.showNames,", }], }, { id: 'vacuum-trail-resume-disabled', guard: 'node scripts/trail-resume-test-guard.mjs', because: 'an ended same-map run inside the accepted 30-minute window must reopen instead ' + 'of rotating into previous; focused pure and recorder sequences must reject the old unconditional split', patches: [{ file: 'custom_components/houseplan/trails.py', find: ' resumed = bool(cur and can_resume_trail_run(cur, map_id, now, route_id))', replace: ' resumed = False', }], }, { id: 'vacuum-trail-smoothing-disabled', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="smoothVacPath rounds corners" test/vacuum.test.mjs', because: 'the bounded curve is the user-visible point of #209; restoring a straight vertex ' + 'must be rejected by the focused geometry test rather than blessed by a matching empty golden', patches: [{ file: 'src/vacuum.ts', find: " commands.push({ kind: 'quadratic', control: b, point: after });", replace: " commands.push({ kind: 'line', point: b });", }], }, { id: 'device-unavailable-hover-restored', guard: 'node demo/smoke_device_icon_design.mjs', because: 'unavailable keeps click/keyboard access but must never regain the blue visual hover ' + 'which makes an offline device look live', patches: [{ file: 'src/styles/devices.styles.ts', find: '.dev:not(.unavail)[data-hp-device-hover] {', replace: '.dev.unavail[data-hp-device-hover] {', }], }, { id: 'controller-availability-follows-target', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 251 separates controller availability" ' + 'test/device-presentation.test.mjs', because: 'a live controller must not inherit unavailable from its controlled lamp; ' + 'the focused matrix keeps own availability and target working as separate facts (#251)', patches: [{ file: 'src/device-presentation-policy.ts', find: " let visual = input.controllerFace\n" + " ? { ...input.sourceVisual, availability: input.controllerAvailability }", replace: " let visual = input.controllerFace\n ? input.sourceVisual", }], }, { id: 'controller-diagnostics-do-not-prove-online', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 251 separates controller availability" ' + 'test/device-presentation.test.mjs', because: 'battery, LQI and update are the available evidence for event-only wireless ' + 'controllers; excluding those siblings restores the field defect (#251)', patches: [{ file: 'src/device-presentation.ts', find: ' const live = ownEntities.some((eid) => {', replace: " const live = ownEntities.filter((eid) => !eid.startsWith('sensor.') " + "&& !eid.startsWith('update.')).some((eid) => {", }], }, { id: 'entityless-active-controller-stays-available', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 318 keeps an active entityless physical controller available" ' + 'test/device-presentation.test.mjs', because: 'an active physical controller whose HA device exposes no own entities has no offline ' + 'evidence; its face must remain available while controls decide working versus neutral (#318)', patches: [{ file: 'src/device-presentation.ts', find: " if (activeEntitylessDevice) return 'available';", replace: " if (activeEntitylessDevice) return 'unavailable';", }], }, { id: 'wireless-controller-loses-filtered-target-role', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 274 keeps a wireless controller" ' + 'test/device-presentation.test.mjs', because: 'a saved target tombstone may empty the runtime light graph without turning the ' + 'physical controller into its event-only primary; live diagnostics still prove online (#274)', patches: [{ file: 'src/device-presentation.ts', find: " const controllerFace = sources.sourceKind === 'controls'\n" + " || (configuredController && sources.sourceKind !== 'light' && sources.sourceKind !== 'cover');", replace: " const controllerFace = sources.sourceKind === 'controls';", }], }, { id: 'wireless-controller-preview-drops-sibling-markers', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 274 keeps a wireless controller" ' + 'test/device-presentation.test.mjs', because: 'the draft must inherit the same tombstones and ownership roster as the saved plan; ' + 'isolating it restores the plan/preview contradiction (#274)', patches: [{ file: 'src/devices.ts', find: ' const markers = [\n' + ' ...siblingMarkers.filter((item) => item.id !== marker.id),\n' + ' marker,\n' + ' ];', replace: ' const markers = [marker];', }], }, { id: 'unavailable-toggle-stays-silent', guard: 'node demo/smoke_controls.mjs', because: 'a configured group with no available target must explain the safe no-op instead ' + 'of returning silently before service, confirmation and press feedback (#251)', patches: [{ file: 'src/houseplan-card.ts', find: ' this._showUnavailableToggleTargets(initial);', replace: ' // Mutant: restore the historical quiet no-op.', }], }, { id: 'partial-group-shows-noop-toast', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 251 classifies only unavailable" ' + 'test/device-toggle.test.mjs', because: 'a partial group did execute its available subset and must never claim that no ' + 'action happened merely because another target was skipped (#251)', patches: [{ file: 'src/device-toggle.ts', find: " if (!intent || intent.kind !== 'group' || toggleOperation(intent)\n" + " || intent.noneReason !== 'configured-targets-missing') return [];", replace: " if (!intent || intent.kind !== 'group') return [];", }], }, { id: 'device-marker-lqi-low-boundary-shifted', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="marker LQI keeps semantic bands" ' + 'test/device-presentation.test.mjs', because: 'marker LQI must keep the owner-approved inclusive <=40 red boundary without ' + 'changing the separate room-fill gradient', patches: [{ file: 'src/device-presentation.ts', find: ' if (lqi <= 40) return \'low\';', replace: ' if (lqi < 40) return \'low\';', }], }, { id: 'presentation-row-contract', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="every documented decision row" ' + 'test/device-presentation-policy.test.mjs', because: 'a removed marker must stay outside the presentation roster; the matrix checks ' + 'the documented pre-resolver lifecycle owner instead of pretending the face can hide it later', patches: [{ file: 'src/devices.ts', find: ' if (m.removed) continue;', replace: ' if (false && m.removed) continue;', }], }, { id: 'device-presentation-policy-lifecycle', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="every documented decision row" ' + 'test/device-presentation-policy.test.mjs', because: 'HA-disabled markers must not leak live faces back into View or the device editor; ' + 'the pure policy test exercises the shared lifecycle gate on both surfaces', patches: [{ file: 'src/device-presentation-policy.ts', find: " if (input.bindingLifecycle === 'ha_disabled') {\n effectiveHidden = true;", replace: " if (input.bindingLifecycle === 'ha_disabled') {\n effectiveHidden = false;", }], }, { id: 'device-presentation-policy-user-hidden', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="every documented decision row" ' + 'test/device-presentation-policy.test.mjs', because: 'a user-hidden marker must stay out of View while the separate design-preview ' + 'exception remains explicit and independently protected', patches: [{ file: 'src/device-presentation-policy.ts', find: ' } else if (input.userHidden && !input.designPreview) {\n' + ' effectiveHidden = true;', replace: ' } else if (input.userHidden && !input.designPreview) {\n' + ' effectiveHidden = false;', }], }, { id: 'device-presentation-policy-user-hidden-preview', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="every documented decision row" ' + 'test/device-presentation-policy.test.mjs', because: 'design preview must keep the saved face of a user-hidden marker instead of ' + 'collapsing it into the ordinary active lifecycle', patches: [{ file: 'src/device-presentation-policy.ts', find: " } else if (input.userHidden) {\n decisions.push('lifecycle.user_hidden_preview');", replace: " } else if (false && input.userHidden) {\n decisions.push('lifecycle.user_hidden_preview');", }], }, { id: 'device-presentation-policy-orphaned', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="every documented decision row" ' + 'test/device-presentation-policy.test.mjs', because: 'an orphaned saved binding must retain its diagnostic lifecycle decision and ' + 'must not be treated as an ordinary active marker', patches: [{ file: 'src/device-presentation-policy.ts', find: " } else if (input.bindingLifecycle === 'orphaned') {\n" + " decisions.push('lifecycle.orphaned_diagnostic');", replace: " } else if (false && input.bindingLifecycle === 'orphaned') {\n" + " decisions.push('lifecycle.orphaned_diagnostic');", }], }, { id: 'presentation-static-source-fast-path', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="every documented decision row" ' + 'test/device-presentation-policy.test.mjs', because: 'the static plan fast path deliberately skips source discovery and must expose ' + 'that bounded decision instead of silently looking like an evaluated empty graph', patches: [{ file: 'src/device-presentation.ts', find: " sourceKind: 'none', decisionIds: ['source.skipped_static_fast_path'],", replace: " sourceKind: 'none', decisionIds: ['source.none'],", }], }, { id: 'device-presentation-policy-static', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="every documented decision row" ' + 'test/device-presentation-policy.test.mjs', because: 'static_icon is the strongest visible face gate and must suppress state, value, ' + 'metrics, pulse and vacuum live together rather than relying on renderer-specific checks', patches: [{ file: 'src/device-presentation-policy.ts', // #588 переименовал переменную: она отвечает уже не за один токен, а за // «нейтральное лицо» обоих статичных режимов. Якорь переехал, смысл нет. find: ' } else if (neutralFace) {\n visual = NEUTRAL_VISUAL;', replace: ' } else if (false && neutralFace) {\n visual = NEUTRAL_VISUAL;', }], }, { id: 'device-presentation-policy-alarm', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="every documented decision row" ' + 'test/device-presentation-policy.test.mjs', because: 'critical alarm must survive live_states:false for every dynamic face; removing ' + 'the alarm exception turns the safety state into an ordinary neutral marker', patches: [{ file: 'src/device-presentation-policy.ts', find: " } else if (input.sourceVisual.status !== 'alarm' && !input.liveStates) {", replace: ' } else if (!input.liveStates) {', }], }, { id: 'device-presentation-policy-unavailable', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="every documented decision row" ' + 'test/device-presentation-policy.test.mjs', because: 'unavailable is a distinct renderer state after lifecycle and alarm; losing its ' + 'decision lets offline markers masquerade as neutral available devices', patches: [{ file: 'src/device-presentation-policy.ts', find: " else if (visual.availability === 'unavailable') decisions.push('status.unavailable');", replace: " else if (false && visual.availability === 'unavailable') decisions.push('status.unavailable');", }], }, { id: 'device-presentation-policy-status', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="every documented decision row" ' + 'test/device-presentation-policy.test.mjs', because: 'working, open and neutral are ordered stable states; the focused table must catch ' + 'a reordered or removed working branch before it changes the marker plate', patches: [{ file: 'src/device-presentation-policy.ts', find: " else if (visual.status === 'working') decisions.push('status.working');", replace: " else if (false && visual.status === 'working') decisions.push('status.working');", }], }, { id: 'device-presentation-policy-live-gate', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="every documented decision row" ' + 'test/device-presentation-policy.test.mjs', because: 'live_states:false must neutralise every ordinary dynamic state without suppressing ' + 'alarm; the matrix proves the gate with observable visual output', patches: [{ file: 'src/device-presentation-policy.ts', find: " } else if (input.sourceVisual.status !== 'alarm' && !input.liveStates) {", replace: " } else if (input.sourceVisual.status !== 'alarm' && false) {", }], }, { id: 'device-presentation-policy-value', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="every documented decision row" ' + 'test/device-presentation-policy.test.mjs', because: 'value mode may render text only with one available scalar source; bypassing that ' + 'single gate either hides valid values or revives ambiguous arbitrary registry rows', patches: [{ file: 'src/device-presentation-policy.ts', // #588: лицо значения теперь спрашивают предикатом, а не сравнением с // одним токеном — режимов со значением стало два. Якорь переехал. find: ' const face: PresentationFace = wantsValue\n' + " && !effectiveHidden && input.valueAvailable ? 'value' : 'icon';", replace: " const face: PresentationFace = false\n" + " && !effectiveHidden && input.valueAvailable ? 'value' : 'icon';", }], }, { id: 'device-presentation-policy-diagnostics', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="every documented decision row" ' + 'test/device-presentation-policy.test.mjs', because: 'metrics and vacuum overlays share the static/hidden gate; forcing metrics on lets ' + 'satellites escape a static face even though the core itself remains neutral', patches: [{ file: 'src/device-presentation-policy.ts', // #588: переменная переименована в `neutralFace` — режимов с нейтральным // лицом стало два. Якорь переехал, смысл прежний. find: ' const metrics = !neutralFace && !effectiveHidden;', replace: ' const metrics = true;', }], }, { id: 'device-presentation-policy-pulse-gate', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="every documented decision row" ' + 'test/device-presentation-policy.test.mjs', because: 'ordinary activity belongs only to icon_ripple while alarm is independent; ' + 'dropping the display gate recreates ambiguous activity on the default badge mode', patches: [{ file: 'src/device-presentation-policy.ts', find: " && visual.availability === 'available'\n" + " && (visual.status === 'alarm' || (input.liveStates && input.display === 'icon_ripple'));", replace: " && visual.availability === 'available'\n" + " && (visual.status === 'alarm' || input.liveStates);", }], }, { id: 'presentation-source-decision-trace', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="every documented decision row" ' + 'test/device-presentation-policy.test.mjs', because: 'source precedence must remain named and executable; erasing the cover winner must ' + 'break the matrix even if the renderer happens to retain the same pixels for one fixture', patches: [{ file: 'src/device-presentation.ts', find: " decisionIds.push('source.cover');", replace: " decisionIds.push('source.cover_mutant');", }], }, { id: 'device-long-value-ellipsis-restored', guard: 'node demo/smoke_device_icon_design.mjs', because: 'Text and Double must expose the complete dynamic value; the new shared shell ' + 'must not regress to the old clipped satellite', patches: [{ file: 'src/styles/devices.styles.ts', find: ' .dev .valtext {\n overflow: visible;', replace: ' .dev .valtext {\n overflow: hidden;\n text-overflow: ellipsis;', }], }, { id: 'device-keyboard-bypasses-click-path', guard: 'node demo/smoke_device_icon_design.mjs', because: 'Enter and Space must reuse the current surface click path so Device editor opens ' + 'settings and secure View actions keep their existing confirmation policy', patches: [{ file: 'src/houseplan-card.ts', find: ' this._clickDevice(ev, d);', replace: ' if (d.primary) this._openMoreInfo(d.primary);', }], }, { id: 'device-visual-factor-removed', guard: 'node --test --test-name-pattern="issue 213 resolves the effective base" ' + 'test/device-marker-polish-contract.test.mjs', because: 'the current effective size must be resolved once at the surface boundary; ' + 'restoring a late face-level 0.9 factor would shrink every marker a second time', patches: [{ file: 'src/styles/devices.styles.ts', find: ' --dev-size: calc(var(--device-base-size, 2.25cqw) * var(--dev-scale, 1));', replace: ' --dev-size: calc(var(--device-base-size, 2.25cqw) * var(--dev-scale, 1) * 0.9);', }], }, { id: 'device-text-capsule-radius-restored', guard: 'node --test --test-name-pattern="issue 212 Text value" ' + 'test/device-marker-polish-contract.test.mjs', because: 'a wide Text core must keep a radius based on its height instead of reverting ' + 'to the elliptical 50% rule', patches: [{ file: 'src/styles/devices.styles.ts', find: ' border-radius: calc(var(--dev-size, var(--icon-size, 2.5cqw)) / 2);', replace: ' border-radius: 50%;', }], }, { id: 'device-press-duration-drifted', guard: 'node --test --test-name-pattern="issue 212 feedback" ' + 'test/device-marker-polish-contract.test.mjs', because: 'accepted device actions must retain the owner-specified bounded 200 ms feedback ' + 'instead of silently drifting with unrelated animation timings', patches: [{ file: 'src/houseplan-card.ts', find: ' duration: 200,', replace: ' duration: 350,', }], }, { id: 'device-touch-hover-gate-removed', guard: 'node --test --test-name-pattern="issue 212 removes the global touch latch" ' + 'test/device-marker-polish-contract.test.mjs', because: 'touch must not leave a browser-matched device hover painted after the JS tooltip ' + 'has already been cleared', patches: [{ file: 'src/styles/devices.styles.ts', find: ' :host([data-pointer-hover]) .dev:not(.unavail)[data-hp-device-hover] {', replace: ' .dev:not(.unavail)[data-hp-device-hover] {', }], }, { id: 'pair-chamfer-returns', // #310: фаска возвращается в узел-двойку — остриё пары снова срезано // вопреки решению владельца. guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 310" test/wall-thickness.test.mjs', because: 'узел ровно двух лучей обязан закрываться полным mitre — стены сходятся в точку', patches: [{ file: 'src/wall-thickness.ts', find: ' const patch = hit\n ? [node.slice(), pA, hit, pB]\n : [node.slice(), pA, pB];', replace: ' const visual = VISUAL_MITRE_LIMIT * Math.max(a.halfDepth, b.halfDepth);\n const patch = hit && Math.hypot(hit[0] - node[0], hit[1] - node[1]) <= visual\n ? [node.slice(), pA, hit, pB]\n : (hit && chamferApex(node, pA, hit, pB, visual)) || [node.slice(), pA, pB];', }], }, { id: 'butt-end-trim-disabled', // #310: торцевой трим отключён — зубец торца толстой стены снова торчит // из тонкой. guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 310" test/wall-thickness.test.mjs', because: 'выступ прямоугольного торца за грань тонкой стены — это и есть зубец из отчёта владельца', patches: [{ file: 'src/physical-geometry.ts', find: ' for (const { segmentIndex, wedge } of pairButtEndTrimWedges(allSegments, epsilon)) {', replace: ' for (const { segmentIndex, wedge } of pairButtEndTrimWedges([], epsilon)) {', }], }, { id: 'butt-end-trim-unbounded', // #310: трим без ограничения окрестностью узла режет всё тело стены. guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 310" test/wall-thickness.test.mjs', because: 'торцевой трим обязан быть адресным — не дальше 2·halfDepth от узла вдоль оси', patches: [{ file: 'src/wall-thickness.ts', find: ' const reach = Math.min(2 * self.halfDepth, self.length);', replace: ' const reach = self.length;', }], }, { id: 'preflight-reason-lost-in-dialog', // #295: диалог обязан называть причину отказа по каждому пространству. // #642: гвард — юнит модуля диалога, разметка исполнением. guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="failed preflight names each reason and offers no Apply" ' + 'test/optimize-plans-dialog.test.mjs', because: 'отказ preflight без причины недиагностируем — ровно исходный дефект #295', patches: [{ file: 'src/optimize-plans-dialog.ts', find: " ${failure.displayName}: ${this.port.t(`gs.preflight_reason_${failure.reason}` as I18nKey)}", replace: " ${failure.displayName}", }], }, { id: 'preflight-fingerprint-from-saved-config', // CODE-REVIEW-295-r1 M1: хэш геометрии обязан браться из кандидата, // который проверял preflight, а не из сохранённого конфига — иначе блок // повторяет то, что и так даст экспорт пространства. // #642: сборка блока — чистая функция, сохранённый конфиг ей не виден; // подмена кандидата сохранённым конфигом возможна только у вызова. guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="hashes the candidate, not the saved config" ' + 'test/optimize-plans-dialog.test.mjs', because: 'диагностика с хэшем сохранённой геометрии не несёт ничего сверх экспорта (AC4)', patches: [{ file: 'src/optimize-plans-dialog.ts', find: " this.diagnostics(preflight, dialog.config ?? null), null, 2,", replace: " this.diagnostics(preflight, this.port.config()), null, 2,", }], }, { id: 'span-cut-erases-the-door-wall', // #316 §3.1: легаси-кат обязан щадить атом, несущий проём — иначе дверь // остаётся на нулевой стене и миграция деградирует без нужды. guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="keeps its wall through a legacy span cut" ' + 'test/wall-segment-model.test.mjs', because: 'дверь внутри бывшей «границы» стояла в настоящей стене — занулять её атом значит переписать план (#316)', patches: [{ file: 'src/wall-segment-model.ts', find: " atom.zeroWall ||= coveredBy(canonicalZeroCuts)\n || (coveredBy(legacyCuts) && !atomCarriesOpening(a, b));", replace: " atom.zeroWall ||= coveredBy(canonicalZeroCuts) || coveredBy(legacyCuts);", }], }, { id: 'migration-throws-over-an-opening-again', // #316 §3.4: initial migration никогда не кидает opening-host — реверт // возвращает глобальный блокер рисования beta.3. guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="migrates unhosted instead of blocking" ' + 'test/wall-segment-model.test.mjs', because: 'один конфликтный проём снова заблокировал бы структурные записи во всех пространствах (#316)', patches: [{ file: 'src/wall-segment-model.ts', find: " if (initialMigration) { delete opening.host; continue; }", replace: " if (initialMigration) throw new WallSegmentModelError('opening-host', opening.id);", }], }, { id: 'preflight-fallback-survives-dialog-close', // CODE-REVIEW-295-r1 M2: инлайн-фолбэк живёт одно показание диалога; // переживший закрытие блок подсунет в отчёт диагностику чужого отказа. // #642: защита — ключ WeakMap по объекту диалога. Мутант подменяет его // общим ключом (сам контроллер), и фолбэк переживает закрытие. guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="belongs to one dialog showing" ' + 'test/optimize-plans-dialog.test.mjs', because: 'застрявший фолбэк отдаёт в баг-репорт JSON предыдущего отказа, не текущего (AC4)', patches: [{ file: 'src/optimize-plans-dialog.ts', find: " this.fallbacks.set(dialog, text);", replace: " this.fallbacks.set(this, text);", }, { file: 'src/optimize-plans-dialog.ts', find: " return dialog ? this.fallbacks.get(dialog) ?? null : null;", replace: " return dialog ? this.fallbacks.get(this) ?? null : null;", }], }, { id: 'preflight-diagnostics-without-reason', // #295: копируемый блок без reason бесполезен для отчёта об ошибке. // #642: блок собирает чистая функция модуля диалога. guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="preflightDiagnostics: candidate hash, reason" ' + 'test/optimize-plans-dialog.test.mjs', because: 'диагностический блок обязан нести reason каждого отказа', patches: [{ file: 'src/optimize-plans-dialog.ts', find: " reason: failure.reason,", replace: " reason: undefined,", }], }, { id: 'preflight-dev-log-disabled', // #295: dev-лог — второй канал диагностики, его потерю обязан ловить смок. // #642: dev-лог и его дедуп живут в модуле диалога. guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="logs one structured record per distinct fingerprint" ' + 'test/optimize-plans-dialog.test.mjs', because: 'структурированная запись отказа в консоли — часть контракта диагностики #295', patches: [{ file: 'src/optimize-plans-dialog.ts', find: " console.warn('[houseplan] optimize preflight failed', this.diagnostics(preflight, candidate));", replace: " void preflight; void candidate;", }], }, { id: 'optimize-dialog-imports-host-port', // #642 AC2: смысл выноса — узкий порт. Модуль, который снова тянет // широкий порт редактора, возвращает связность, ради снижения которой // и выносился, а юнит должен это увидеть даже для `import type`. guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="the dialog port is narrow" ' + 'test/optimize-plans-dialog.test.mjs', because: 'диалог, получивший HouseplanEditorHostPort, снова связан с 350 членами карточки (#642)', patches: [{ file: 'src/optimize-plans-dialog.ts', find: "import { html, nothing, type TemplateResult } from 'lit';\n", replace: "import { html, nothing, type TemplateResult } from 'lit';\n" + "import type { HouseplanEditorHostPort } from './houseplan-editor-runtime';\n", }, { file: 'src/optimize-plans-dialog.ts', find: " private reportedFingerprint: string | null = null;\n", replace: " private reportedFingerprint: string | null = null;\n" + " public host: HouseplanEditorHostPort | null = null;\n", }], }, { id: 'visual-mitre-limit-back-to-4', // #309: порог визуального среза возвращается к классическим 4·h — шип на // острой паре и горб над узлом 3×50 отрастают обратно. guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 309" test/wall-thickness.test.mjs', because: 'без визуального лимита mitre снова торчит на 2–4 толщины за габарит узла', patches: [{ file: 'src/wall-thickness.ts', find: 'export const VISUAL_MITRE_LIMIT = 1.5;', replace: 'export const VISUAL_MITRE_LIMIT = 4;', }], }, { id: 'chamfer-disabled-full-mitre', // #309: фаска отключена — сверх лимита рисуется сырой mitre. guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 309" test/wall-thickness.test.mjs', because: 'сектор сверх визуального лимита обязан закрываться фаской, а не вершиной', patches: [{ file: 'src/wall-thickness.ts', find: ' const ux = apex[0] - node[0], uy = apex[1] - node[1];\n const d = Math.hypot(ux, uy);\n if (!(d > 0) || d <= limit + 1e-9) return null;', replace: ' const ux = apex[0] - node[0], uy = apex[1] - node[1];\n const d = Math.hypot(ux, uy);\n if (d >= 0) return null;', }], }, { id: 'pair-patches-at-multiwall-nodes', // #309: скип узлов ≥3 лучей отключён — парный патч через чужой сектор // снова красит ступень на кресте смешанных толщин. guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 309" test/wall-thickness.test.mjs', because: 'парный патч в узле ≥3 лучей живёт в чужом секторе и красит ступень поверх тонких полос', patches: [{ file: 'src/wall-thickness.ts', find: ' for (const node of nodes) {\n if (coveredByFans(node)) continue;', replace: ' for (const node of nodes) {', }], }, { id: 'chamfer-chord-instead-of-perpendicular', // #309: срез хордой pA–pB вместо перпендикуляра на пороге — это возврат // формы #249 (плоский бевел у самого узла), сектор худеет. guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 309" test/wall-thickness.test.mjs', because: 'фаска обязана резать перпендикулярно направлению вершины на пороге, а не хордой между гранями', patches: [{ file: 'src/wall-thickness.ts', find: ' const cA = cut(pA), cB = cut(pB);\n if (!cA || !cB) return null;\n return [[node[0], node[1]], pA, cA, cB, pB];', replace: ' return null;', }], }, { id: 'junction-fans-disabled', // Юниты формы, не детектор: контрактные пробы детектора строятся из той // же функции и слепнут вместе с ней, а «T-узел даёт два веера» — внешняя // истина, не зависящая от мутируемого кода. guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 302" test/wall-thickness.test.mjs', because: 'без вееров сектор между соседними полосами узла остаётся дырой — ' + 'это и есть класс артефактов #302', patches: [{ file: 'src/wall-thickness.ts', find: ' push(chamferApex([P[0], P[1]], EA, mitre, EB, visual)\n ?? [[P[0], P[1]], EA, mitre, EB]);\n continue;', replace: ' continue;', }, { file: 'src/wall-thickness.ts', find: ' push([[P[0], P[1]], EA, A2, B2, EB]);', replace: ' void A2; void B2;', }], }, { id: 'junction-fan-ignores-thick-length', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="issue 302" test/wall-thickness.test.mjs', because: 'mitre, шагнувший за короткий толстый саппорт, рисует латеральный ' + 'фантом рядом с тонким продолжением (#271)', patches: [{ file: 'src/wall-thickness.ts', find: ' : tA > 1e-9 && tA <= A.thickLength && tB <= B.thickLength;', replace: ' : tA > 1e-9;', }], }, { id: 'junction-reflex-outer-mitre-missing', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="issue 302" test/wall-thickness.test.mjs', because: 'рефлексный сектор — наружный угол между крайними лучами; без ' + 'обратного mitre там остаётся вырез Y-60 из отчёта владельца', patches: [{ file: 'src/wall-thickness.ts', find: ' const directionOk = reflex\n ? tA <= 1e-9 && tB <= 1e-9', replace: ' const directionOk = reflex\n ? false', }], }, { id: 'junction-fan-limit-back-to-249', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="issue 309 the 57" test/wall-thickness.test.mjs', because: 'лимит веера 1.25·h — это отставка решения №5: узлы снова с ' + 'вырезами и ступеньками вместо полного mitre', patches: [{ file: 'src/wall-thickness.ts', find: ' const limit = MITRE_LIMIT * Math.max(A.halfDepth, B.halfDepth);\n // Facing strip edges', replace: ' const limit = node.limit;\n // Facing strip edges', }], }, { id: 'junction-detector-blind', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="issue 302" test/wall-thickness.test.mjs', because: 'слепой детектор превращает инвариант «нет дыр» в декорацию — ' + 'самопроверка на заведомо дырявой фикстуре обязана краснеть', patches: [{ file: 'src/wall-thickness.ts', find: ' if (!inGeometry(geometry, x, y)) holes.push([x, y]);', replace: ' void x; void y;', }], }, { id: 'junction-pieces-unbounded', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="concave vertex" test/wall-thickness.test.mjs', because: 'куски узла без клипа гладкой фасадной границей отращивают новый ' + 'фасад на вогнутой вершине', patches: [{ file: 'src/wall-thickness.ts', find: ' if (bound) ring = intersection(ring, bound);', replace: ' void bound;', }], }, { id: 'hatch-step-ignores-cell-cm', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 230" test/wall-thickness.test.mjs', because: 'шаг-константа — это и есть баг #230: одна и та же стена 15 см ' + 'получала от 0.3 до 7.8 полос в зависимости от масштаба сетки', patches: [{ file: 'src/wall-thickness.ts', find: ' const step = HATCH_BASE_STEP_UNITS * (HATCH_REFERENCE_CELL_CM / c);', replace: ' const step = HATCH_BASE_STEP_UNITS;', }], }, { id: 'hatch-step-inverted', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 230" test/wall-thickness.test.mjs', because: 'множитель cell/5 вместо 5/cell — ошибка из описания issue; она не ' + 'убирает разброс, а увеличивает его', patches: [{ file: 'src/wall-thickness.ts', find: ' const step = HATCH_BASE_STEP_UNITS * (HATCH_REFERENCE_CELL_CM / c);', replace: ' const step = HATCH_BASE_STEP_UNITS * (c / HATCH_REFERENCE_CELL_CM);', }], }, { id: 'hatch-step-unclamped', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 230" test/wall-thickness.test.mjs', because: 'без клампа патологический cell_cm рождает паттерн, который либо ' + 'сливается в заливку, либо не попадает в стену ни одной полосой', patches: [{ file: 'src/wall-thickness.ts', find: ' return Math.min(HATCH_MAX_STEP_UNITS, Math.max(HATCH_MIN_STEP_UNITS, step));', replace: ' return step;', }], }, { id: 'hatch-density-solid-threshold-off', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="issue 230" test/wall-thickness.test.mjs', because: 'порог по шагу на экране — единственная защита от каши на дальнем ' + 'конце зума после того, как компенсация 1/zoom убрана', patches: [{ file: 'src/wall-thickness.ts', find: ' && stepUnits * pxPerUnit < HATCH_MIN_STEP_PX;', replace: ' && false;', }], }, { id: 'hatch-stroke-not-scaled', guard: 'node demo/smoke_wall_hatch_density.mjs', because: 'штрих обязан следовать за шагом: иначе на мелкой клетке полосы ' + 'слипаются в сплошное пятно, а на крупной становятся волосяными', patches: [{ file: 'src/houseplan-card.ts', find: ' const stripe = 2 * (step / HATCH_BASE_STEP_UNITS);', replace: ' const stripe = 2;', }], }, { id: 'hatch-zoom-compensation-back', guard: 'node demo/smoke_wall_hatch_density.mjs', because: 'компенсация 1/zoom возвращает ровно то, ради устранения чего ' + 'задача и делалась: стена меняет вид при зуме (решение владельца §4.2)', patches: [{ file: 'src/houseplan-card.ts', find: ' width="${step}" height="${step}" patternTransform="rotate(45)">', replace: ' width="${step}" height="${step}"\n' + ' patternTransform="rotate(45) scale(${Math.max(0.4, 1 / Math.max(this._zoom, 0.4)).toFixed(3)})">', }], }, { id: 'hatch-static-renderer-untouched', guard: 'node demo/smoke_wall_hatch_density.mjs', because: 'статический рендерер — второй путь, рисующий тело стены; все ' + 'golden-сцены снимаются при cell_cm 5, где его константа неотличима', patches: [{ file: 'src/space-render.ts', find: ' const hatchStep = wallHatchStepUnits(cellCm);', replace: ' const hatchStep = 8;', }], }, { id: 'partition-merge-rescales-rooms', guard: 'node --test --test-name-pattern="issue 229" test/wall-merge.test.mjs', because: 'комнаты хранятся в тех же координатах, что перегородки: лишнее деление ' + 'уносит их в угол и примыкание к стене комнаты перестаёт находиться ' + '(CODE-REVIEW-229-r1, High-1)', patches: [{ file: 'src/wall-merge.ts', find: ` .filter((poly: number[][] | null): poly is number[][] => !!poly),`, replace: ` .filter((poly: number[][] | null): poly is number[][] => !!poly) .map((poly: number[][]) => poly.map((p) => [p[0] / 1000, p[1] / 1000])),`, }], }, { id: 'partition-merge-ignores-thickness', guard: 'node --test --test-name-pattern="issue 229" test/wall-merge.test.mjs', because: 'сращивание отрезков разной толщины стирает намеренный переход толщины стены', patches: [{ file: 'src/wall-merge.ts', find: ' if (p.cm !== q.cm) return null;', replace: ' if (false) return null;', }], }, { id: 'partition-merge-ignores-junction', guard: 'node --test --test-name-pattern="issue 229" test/wall-merge.test.mjs', because: 'слияние сквозь примыкание убирает узел, который держит третья стена, ' + 'комната, колонна или черновик — §8.2 ТЗ', patches: [{ file: 'src/wall-merge.ts', find: ' if (junctionAt(at, list, new Set([list[i].id, list[j].id]), options.geometry, join)) continue;', replace: ' if (false) continue;', }], }, { id: 'junction-checks-room-vertices-only', guard: 'node --test --test-name-pattern="issue 229" test/wall-merge.test.mjs', because: 'поиск примыкания комнаты только по вершинам пропускает T-стык к середине ' + 'комнатной стены — штатный случай продукта (docs/specs/141-wall-junctions.md)', patches: [{ file: 'src/wall-merge.ts', find: ' if (finite(a) && finite(b) && distToSegment(point, a, b) <= join) return true;', replace: ' if (finite(a) && dist(point, a) <= join) return true; void b;', }], }, { id: 'partition-merge-keeps-relative-t', guard: 'node --test --test-name-pattern="issue 229" test/wall-merge.test.mjs', because: 'сохранение прежней доли вместо пересчёта двигает дверь вдоль стены: ' + 'позиция проёма относительна длине хозяина, а она при слиянии меняется', patches: [{ file: 'src/wall-merge.ts', find: ' const mapped = move.base + t * move.span;', replace: ' const mapped = t;', }], }, { id: 'partition-merge-skips-materialization', guard: 'node --test --test-name-pattern="issue 229" test/wall-merge.test.mjs', because: 'устаревшие x/y/angle — это то, что рисует старый читатель конфига; ' + 'слияние канонизирует направление стены, поэтому проекция обязана ' + 'переписаться (docs/CONFIG-COMPATIBILITY.md, #132)', patches: [{ file: 'src/wall-merge.ts', find: ` if (resolved) Object.assign( opening, materializePartitionOpening(opening, resolved, ctx.coordScale), );`, replace: ' void resolved;', }], }, { id: 'chain-merge-sweeps-whole-space', guard: 'node --test --test-name-pattern="issue 229" test/wall-merge.test.mjs', because: 'слияние всего пространства при рисовании молча правит старые швы в стороне, ' + 'минуя отчёт и отмену, которые обещаны им в «Оптимизировать планы» (§8.6 ТЗ)', patches: [{ file: 'src/wall-merge.ts', find: ' if (seeds && !seeds.has(list[i].id) && !seeds.has(list[j].id)) continue;', replace: ' if (false) continue;', }], }, // #647: шапка — без счётчика, × в собственном слоте постоянного размера. { id: 'toolbar-device-count-returns', guard: 'node demo/smoke_toolbar_stable_width.mjs', because: '#647 AC1: the header must not show a device count in any mode or locale', patches: [{ file: 'src/houseplan-card.ts', find: " const editorClose = html`${this._devices.length} dev.${this._t(('mode.' + m) as any)}\n ${this._mode === m ? editorClose : nothing}`", replace: " ${this._t(('mode.' + m) as any)}${this._mode === m ? html`` : nothing}\n ${this._mode === m ? editorClose : nothing}`", }], }, { id: 'toolbar-active-highlight-stops-at-tab', guard: 'node demo/smoke_toolbar_stable_width.mjs', because: '#666: the active editor tab highlight must cover the gap and the X slot, measured as painted pixels', patches: [{ file: 'src/styles/chrome.styles.ts', find: " right: calc(-1 * (var(--sp-1) + var(--hp-editor-close-size)));", replace: " right: 0;", }], }, { id: 'toolbar-close-slot-idle-focusable', guard: 'node demo/smoke_toolbar_stable_width.mjs', because: '#647 AC4: outside an editor the reserve is inert and hidden from assistive technology', patches: [{ file: 'src/houseplan-card.ts', find: "", replace: "", }], }, { id: 'toolbar-close-target-below-24', guard: 'node demo/smoke_toolbar_stable_width.mjs', because: '#647 AC5 / #195: the X keeps a hit target of at least 24 x 24 px around its 13 px glyph', patches: [{ file: 'src/styles/chrome.styles.ts', find: " --hp-editor-close-size: 24px;", replace: " --hp-editor-close-size: 13px;", }], }, { id: 'toolbar-close-hidden-at-medium-width', guard: 'node demo/smoke_toolbar_stable_width.mjs', because: '#647 AC6: the X must stay visible on ordinary 721-1100 px windows, where the old count was hidden', patches: [{ file: 'src/styles/chrome.styles.ts', find: " .editor-close-slot .closex ha-icon { --mdc-icon-size: 13px; }", replace: " .editor-close-slot .closex ha-icon { --mdc-icon-size: 13px; }\n @media (max-width: 1100px) { .editor-close-slot .closex { display: none; } }", }], }, // #660: compact stable header, desktop summary controls and neutral Escape. { id: 'toolbar-close-slot-leaves-mode-group', guard: 'node demo/smoke_toolbar_stable_width.mjs', because: '#660 AC2: the one fixed X reserve belongs inside the segmented mode group, after the active button', patches: [{ file: 'src/houseplan-card.ts', find: ' ${this._mode === m ? editorClose : nothing}`', replace: ' `', }, { file: 'src/houseplan-card.ts', find: " ${this._mode === 'view' ? editorClose : nothing}\n `", replace: ' ${editorClose}`', }], }, { id: 'toolbar-mode-zoom-gap-regresses', guard: 'node demo/smoke_toolbar_stable_width.mjs', because: '#660 AC3: the modes-to-zoom distance is the specified compact 50/40.5 px, not the old flexible gap', patches: [{ file: 'src/styles/chrome.styles.ts', find: ' .head > .spacer { flex: 0 0 30px; }', replace: ' .head > .spacer { flex: 0 0 80px; }', }], }, { id: 'editor-summary-controls-return-to-view-only', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/summary-panel-runtime.test.mjs', because: '#660 AC1: wide-screen inline summary actions remain usable in all three editors', patches: [{ file: 'src/summary-panel-runtime-loaded.ts', find: " if (kiosk && this.host._mode !== 'view') return nothing;", replace: " if (this.host._mode !== 'view') return nothing;", }], }, { id: 'phone-editor-close-hidden-with-mode-buttons', guard: 'node demo/smoke_mobile_view_header.mjs', because: '#660 AC4: hiding desktop mode buttons on a phone must leave the editor X slot visible', patches: [{ file: 'src/styles/chrome.styles.ts', find: ' .head > .title, .head > .spacer, .head > .header-action,', replace: ' .head > .title, .head > .modes, .head > .spacer, .head > .header-action,', }], }, { id: 'editor-neutral-escape-does-not-exit', guard: 'node demo/smoke_editor_tabs.mjs', because: '#660 AC5: after inner actions have had priority, a neutral Escape returns every editor to View', patches: [{ file: 'src/houseplan-card.ts', find: " e.preventDefault();\n this._setMode('view');\n }\n /** Remove the last transient point", replace: " e.preventDefault();\n }\n /** Remove the last transient point", }], }, { id: 'phone-header-shows-title', guard: 'node demo/smoke_mobile_view_header.mjs', because: '#616 AC1: on a phone the card title alone takes a row; the one-row header must hide it', patches: [{ file: 'src/styles/chrome.styles.ts', find: ' .head > .title, .head > .spacer, .head > .header-action,', replace: ' .head > .spacer, .head > .header-action,', }], }, { id: 'tab-editing-without-write-access', guard: 'node demo/smoke_mobile_view_header.mjs', because: '#616 AC1: a household member has no write access — per-tab gears must not render for them at any width', patches: [{ file: 'src/houseplan-card.ts', find: ' ${s.title}${this._norm && this._canEdit\n', replace: ' ${s.title}${this._norm\n', }], }, { id: 'phone-header-wraps', guard: 'node demo/smoke_mobile_view_header.mjs', because: '#616 AC2: the phone header is one row of at most 56 px; wrapping brings back the three-row chrome', patches: [{ file: 'src/styles/chrome.styles.ts', find: ' .hdr > .head { flex-wrap: nowrap; padding: 5px 8px; gap: 6px; }', replace: ' .hdr > .head { flex-wrap: wrap; padding: 5px 8px; gap: 6px; }', }], }, { id: 'header-menu-drops-pdf', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/header-menu.test.mjs', because: '#616 AC3: every header action the phone row drops must be an item of the gear menu', patches: [{ file: 'src/header-menu.ts', find: " { id: 'pdf', icon: 'mdi:printer-outline', label: t('title.export_pdf'), run: actions.pdf },\n", replace: '', }], }, { id: 'header-menu-item-keeps-menu-open', guard: 'node demo/smoke_mobile_view_header.mjs', because: '#616 AC3: an item does what its button did and closes the menu; a menu left open covers the plan', patches: [{ file: 'src/header-menu.ts', find: ' const run = (item: HeaderMenuItem) => { this.close(false); item.run(); };', replace: ' const run = (item: HeaderMenuItem) => { item.run(); };', }], }, { id: 'header-menu-outside-tap-reaches-plan', guard: 'node demo/smoke_mobile_view_header.mjs', because: '#616 AC4: the tap that dismisses the menu must not toggle a light under it', patches: [{ file: 'src/styles/chrome.styles.ts', find: ' position: fixed;\n inset: 0;\n z-index: 1;\n background: transparent;', replace: ' position: fixed;\n inset: auto;\n z-index: 1;\n background: transparent;', }], }, { id: 'header-menu-escape-ignored', guard: 'node demo/smoke_mobile_view_header.mjs', because: '#616 AC4: Escape closes the menu and returns focus to the gear', patches: [{ file: 'src/header-menu.ts', find: " if (event.key !== 'Escape' || !this.open) return;", replace: " if (event.key !== 'Esc' || !this.open) return;", }], }, { id: 'active-tab-not-revealed', guard: 'node demo/smoke_mobile_view_header.mjs', because: '#616 AC5: with many spaces the active tab must be scrolled into view on load and on switch', patches: [{ file: 'src/header-menu.ts', find: ' if (!nav || !tab || nav.clientWidth === 0) return;', replace: ' if (!nav || !tab || nav.clientWidth >= 0) return;', }], }, { id: 'header-menu-item-below-44', guard: 'node demo/smoke_mobile_view_header.mjs', because: '#616 AC6: menu items are touch targets of at least 44 px', patches: [{ file: 'src/styles/chrome.styles.ts', find: ' min-height: 44px;\n padding: 0 var(--sp-4);', replace: ' min-height: 30px;\n padding: 0 var(--sp-4);', }], }, { id: 'phone-header-at-tablet-width', guard: 'node demo/smoke_mobile_view_header.mjs', because: '#616 AC7: above 480 px the ordinary header stays exactly as it was', patches: [{ file: 'src/styles/chrome.styles.ts', find: ' @media (max-width: 480px) {\n .hdr > .head', replace: ' @media (max-width: 768px) {\n .hdr > .head', }], }, { id: 'tab-reorder-not-persisted', guard: 'node demo/smoke_space_tab_reorder.mjs', because: 'перестановка вкладок, оставшаяся только в памяти, выглядит рабочей ровно до ' + 'перезагрузки страницы — смок обязан требовать запись на сервер', patches: [{ file: 'src/houseplan-card.ts', find: ' cfg.spaces = applySpaceOrder(cfg.spaces || [], order);\n this._saveConfig();', replace: ' cfg.spaces = applySpaceOrder(cfg.spaces || [], order);', }], }, { id: 'tab-drag-target-follows-captured-source', guard: 'node demo/smoke_space_tab_reorder.mjs', because: 'pointer capture keeps pointermove targeted at the held tab; resolving the drop ' + 'from that event target reproduces #243, where a real mouse can never reach another tab', patches: [{ file: 'src/houseplan-card.ts', find: ' const target = this._tabDropTargetAt(event.clientX, event.clientY, drag.id);', replace: ' const target = this._tabDropTargetAt(drag.x, drag.y, drag.id);', }], }, { id: 'tab-drop-indicator-always-before', guard: 'node demo/smoke_space_tab_reorder.mjs', because: 'a single undirected marker cannot tell whether the held space will land before ' + 'or after the target; #243 requires the divider on the actual insertion side', patches: [{ file: 'src/houseplan-card.ts', find: " return { targetId, placement: targetIndex < sourceIndex ? 'before' : 'after' };", replace: " return { targetId, placement: 'before' };", }], }, { id: 'tab-drop-outside-commits-last-target', guard: 'node demo/smoke_space_tab_reorder.mjs', because: 'leaving the tab strip must clear the preview and make release a no-op; retaining ' + 'the last target makes an outside drop reorder spaces unexpectedly', patches: [{ file: 'src/houseplan-card.ts', find: ` targetId: target?.targetId || null, placement: target?.placement || null,`, replace: ` targetId: target?.targetId || drag.targetId, placement: target?.placement || drag.placement,`, }, { file: 'src/houseplan-card.ts', find: ` const target = event.type === 'pointerup' && drag?.moved ? this._tabDropTargetAt(event.clientX, event.clientY, drag.id) : null;`, replace: ` const target = event.type === 'pointerup' && drag?.moved ? drag.targetId ? { targetId: drag.targetId } : null : null;`, }], }, { id: 'reorder-skips-materialization', guard: 'node demo/smoke_space_tab_reorder.mjs', because: 'без материализации привязки маркер без space и area уезжает вслед за порядком: ' + 'его пространство решает firstSpaceId, а тот после перестановки другой (#220 §8.3)', patches: [{ file: 'src/houseplan-card.ts', find: ' const byId = new Map(pinned.map((entry) => [entry.id, entry.space]));', replace: ' const byId = new Map();', }], }, { id: 'materialization-touches-bound-markers', guard: 'node --test --test-name-pattern="issue 220" test/space-order.test.mjs', because: 'материализация обязана трогать только маркеры, чьё пространство решает порядок; ' + 'запись space маркеру, закреплённому HA-областью, кладёт в конфиг поле, которое ' + 'сдвинет его в старое первое пространство в день смены области (ревью r1, H1)', patches: [{ file: 'src/space-order.ts', find: ' if (area && areaToSpace[area]) continue;', replace: ' if (false) continue;', }], }, { id: 'tab-drag-survives-release-outside', guard: 'node demo/smoke_space_tab_reorder.mjs', because: 'мышь, отпущенная мимо панели, обязана завершить жест: иначе перетаскивание ' + 'зависает с moved:true и съедает следующий клик по вкладке (ревью r1, M1)', patches: [{ file: 'src/houseplan-card.ts', find: " window.addEventListener('pointerup', this._tabDragRelease);", replace: ' void 0;', }], }, { id: 'tab-drag-outlives-the-card', guard: 'node demo/smoke_space_tab_reorder.mjs', because: 'слушатели жеста, пережившие disconnectedCallback, держат инстанс карточки ' + 'и дают невидимой карточке записать порядок по следующему pointerup на странице ' + '(ревью r2/r3, F1)', patches: [{ file: 'src/houseplan-card.ts', find: ` this._endTabDrag(); clearTimeout(this._tabSuppressClickTimer);`, replace: ' clearTimeout(this._tabSuppressClickTimer);', }], }, { id: 'tab-reorder-eats-click', guard: 'node --test --test-name-pattern="issue 220" test/space-order.test.mjs', because: 'нулевой порог превращает обычный клик по вкладке в перетаскивание, и ' + 'переключение пространств — основное действие панели — перестаёт работать', patches: [{ file: 'src/space-order.ts', find: 'export const TAB_DRAG_THRESHOLD_PX = 4;', replace: 'export const TAB_DRAG_THRESHOLD_PX = 0;', }], }, { id: 'tab-reorder-ignores-pointer-type', guard: 'node --test --test-name-pattern="issue 220" test/space-order.test.mjs', because: 'на тач-устройстве вкладки — это навигация View, где тап обязан оставаться тапом; ' + 'решение владельца «Touch editor: not exposed» держится именно этой проверкой', patches: [{ file: 'src/space-order.ts', find: " if (ctx.pointerType !== 'mouse') return false;", replace: ' if (false) return false;', }], }, { id: 'internal-path-ignores-query', guard: 'node scripts/backend-test-guard.mjs issue_225', because: 'разбор url строкой вместо urlsplit возвращает баг #225: кэш-бастер ' + '?v=… делает имя файла не равным самому себе, ссылка читается как ' + 'внутренняя-но-неканоническая, и бэкап с вложением снова не импортируется', patches: [{ file: 'custom_components/houseplan/import_export.py', find: ' parsed = urlsplit(url)\n if parsed.scheme or parsed.netloc:\n return None\n url = parsed.path', replace: ' url = url.split("?", 1)[0]', }], }, { id: 'internal-path-trusts-foreign-host', guard: 'node scripts/backend-test-guard.mjs issue_225', because: 'доверие к path при наличии scheme/netloc позволяет ' + '"https://evil.example/houseplan_files/files/m1/doc.pdf" разрешиться в локальный ' + 'файл, хотя _looks_internal считает такую ссылку внешней (ревью r1, M1)', patches: [{ file: 'custom_components/houseplan/import_export.py', find: ' if parsed.scheme or parsed.netloc:\n return None', replace: ' if False:\n return None', }], }, { id: 'internal-path-allows-traversal', guard: 'node scripts/backend-test-guard.mjs issue_225', because: 'структурные проверки пути — единственное, что режет traversal после ' + 'отделения query; снимать их нельзя. Мутант снимает обе сразу: по одной ' + 'защита эшелонирована (sanitize-сравнение ловит "..") и мутант был бы ' + 'эквивалентным — выяснено прогоном при реализации', patches: [{ file: 'custom_components/houseplan/import_export.py', find: ' if not raw_name or "/" in raw_name:\n return None', replace: ' raw_name = raw_name.split("/")[-1]\n if not raw_name:\n return None', }, { file: 'custom_components/houseplan/import_export.py', find: ' tail = url[len(prefix):].split("/")\n if len(tail) != 2:\n return None', replace: ' tail = url[len(prefix):].split("/")[-2:]', }], }, { id: 'opening-dimensions-use-axis-ends', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="room dimensions stop at inner faces" ' + 'test/opening-dimensions.test.mjs', because: 'a placement ruler must stop at the physical inner face endpoints, not silently ' + 'return to the room-axis endpoints that #238 replaces', patches: [{ file: 'src/opening-dimensions.ts', find: ' const [lo, hi] = run;', replace: ' const [lo, hi] = [basis.targetLo, basis.targetHi];', }], }, { id: 'opening-dimensions-collapse-shared-side', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="shared wall keeps two independently resolved room sides" ' + 'test/opening-dimensions.test.mjs', because: 'a shared wall has two independently inset room faces and must render four ' + 'measurements; collapsing two owners back to one pair loses real geometry', patches: [{ file: 'src/opening-dimensions.ts', find: ' if (owners.length > 2) return fallbackDimensions(basis);', replace: ' if (owners.length > 1) return fallbackDimensions(basis);', }], }, { id: 'opening-dimensions-use-crossing-axis', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="independent T junction measures to the near masonry face" ' + 'test/opening-dimensions.test.mjs', because: 'an independent partition ruler must meet the near physical face of a crossing ' + 'wall; shrinking the boundary body to its axis recreates the incorrect distance', patches: [{ file: 'src/opening-dimensions.ts', find: ' halfDepth,\n });', replace: ' halfDepth: epsilon,\n });', }], }, { id: 'opening-dimension-overlay-hidden', guard: 'node demo/smoke_opening_inner_distances.mjs', because: 'correct numbers alone do not identify which physical span they describe; the ' + 'placement preview must keep its dimension lines and endpoint ticks visible', patches: [{ file: 'src/houseplan-card.ts', find: ' ${opMeasure ? this._renderOpeningDimensionGuides(opMeasure) : nothing}', replace: ' ${nothing}', }], }, { id: 'grid-scale-visual-factor-constant', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="visual scale preserves" test/grid-scale.test.mjs', because: 'the visual baseline is 5 cm per cell, so a finer coordinate grid must enlarge ' + 'legacy SVG visual constants by the reciprocal factor (#239)', patches: [{ file: 'src/grid-scale.ts', find: ' return GRID_VISUAL_REFERENCE_CELL_CM / value;', replace: ' return 1;', }], }, { id: 'grid-scale-visual-factor-inverted', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="visual scale preserves" test/grid-scale.test.mjs', because: 'cell/5 shrinks visual constants on a finer grid, exactly reversing the physical ' + 'equivalence contract (#239)', patches: [{ file: 'src/grid-scale.ts', find: ' return GRID_VISUAL_REFERENCE_CELL_CM / value;', replace: ' return value / GRID_VISUAL_REFERENCE_CELL_CM;', }], }, { id: 'grid-scale-opening-symbol-unscaled', guard: 'node demo/smoke_grid_scale_invariance.mjs', because: 'door leaves, window bars and gate panels are visual geometry and must keep the ' + 'same screen footprint on physically equivalent grids (#239)', patches: [{ file: 'src/render/opening-symbol.ts', find: ' const visualScale = gridVisualScale(spec.cellCm);', replace: ' const visualScale = 1;', }], }, { id: 'grid-scale-opening-hit-unscaled', guard: 'node demo/smoke_grid_scale_invariance.mjs', because: 'the opening hover outline and action hitbox must not shrink when cell_cm becomes ' + 'more precise; the smoke also clicks the real outer edge (#239)', patches: [{ file: 'src/houseplan-card.ts', find: ' const outlinePad = gridVisualUnits(10, this._cellCm);\n' + ' const hitPad = gridVisualUnits(12, this._cellCm);', replace: ' const outlinePad = 10;\n const hitPad = 12;', }], }, { id: 'grid-scale-plan-chrome-unscaled', guard: 'node demo/smoke_grid_scale_invariance.mjs', because: 'Plan architecture ink is visual chrome; leaving the wall outline at raw SVG units ' + 'makes it five times thinner on a 1 cm grid (#239)', patches: [{ file: 'src/styles/plan.styles.ts', find: ' stroke-width: calc(0.6px * var(--hp-cell-visual-scale, 1));', replace: ' stroke-width: 0.6px;', }], }, { id: 'grid-scale-static-factor-missing', guard: 'node demo/smoke_grid_scale_invariance.mjs', because: 'the secondary space card shares visual CSS with the full card and must receive ' + 'the same per-space factor instead of silently drifting (#239)', patches: [{ file: 'src/space-render.ts', find: ';--hp-cell-visual-scale:${gridVisualScale(cellCm)}', replace: '', }], }, { id: 'grid-scale-iso-height-unscaled', guard: 'node demo/smoke_grid_scale_invariance.mjs', because: 'hidden isometric wall and floor heights are visual geometry and must project to ' + 'the same raster at 1 and 5 cm per cell (#239)', patches: [{ file: 'src/iso-scene-render.ts', find: ' const wallHeight = gridVisualUnits(ISO_WALL_HEIGHT, input.cellCm);\n' + ' const floorEdgeHeight = gridVisualUnits(ISO_FLOOR_EDGE_HEIGHT, input.cellCm);\n' + ' const raisedHeight = gridVisualUnits(ISO_RAISED_OVERLAY_HEIGHT, input.cellCm);\n' + ' const cached = lruRead(input.cache, input.source.key);\n' + ' let value = cached.hit ? cached.value : undefined;', replace: ' const wallHeight = ISO_WALL_HEIGHT;\n' + ' const floorEdgeHeight = ISO_FLOOR_EDGE_HEIGHT;\n' + ' const raisedHeight = gridVisualUnits(ISO_RAISED_OVERLAY_HEIGHT, input.cellCm);\n' + ' const cached = lruRead(input.cache, input.source.key);\n' + ' let value = cached.hit ? cached.value : undefined;', }], }, { id: 'grid-scale-metric-default-five', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="new-space defaults" test/grid-scale.test.mjs', because: 'new metric spaces must start at 1 cm per cell; restoring the old 5 cm default ' + 'would preserve the defect for every new plan (#239)', patches: [{ file: 'src/grid-scale.ts', find: ' return imperial ? GRID_IMPERIAL_CELL_CM : 1;', replace: ' return imperial ? GRID_IMPERIAL_CELL_CM : 5;', }], }, { id: 'grid-scale-imperial-default-wrong', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="new-space defaults" test/grid-scale.test.mjs', because: 'one imperial grid point means exactly one inch (2.54 canonical centimetres), ' + 'not one centimetre (#239)', patches: [{ file: 'src/grid-scale.ts', find: ' return imperial ? GRID_IMPERIAL_CELL_CM : 1;', replace: ' return imperial ? 1 : 1;', }], }, { id: 'grid-scale-legacy-fallback-one', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="legacy space without cell_cm" test/canvas.test.mjs', because: 'missing cell_cm is stored-data compatibility and stays at the historical 5 cm; ' + 'the new 1 cm value applies only at creation (#239)', patches: [{ file: 'src/space-geometry.ts', find: ' cellCm: Number.isFinite(Number(s.cell_cm)) && Number(s.cell_cm) > 0\n' + ' ? Number(s.cell_cm) : 5,', replace: ' cellCm: Number.isFinite(Number(s.cell_cm)) && Number(s.cell_cm) > 0\n' + ' ? Number(s.cell_cm) : 1,', }], }, { id: 'grid-scale-imperial-roundtrip-drift', guard: 'node demo/smoke_space_scale_defaults.mjs', because: 'the rounded imperial field is presentation only; saving it without an edit must ' + 'not replace the exact canonical centimetre value (#239)', patches: [{ file: 'src/houseplan-card.ts', find: ' sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0\n' + ' ? Math.max(CELL_CM_MIN, Math.min(CELL_CM_MAX, d.cellCm)) : 5;', replace: ' sp.cell_cm = gridCellFieldToCm(Number(d.cellCmInput), this._imperial);', }], }, { id: 'grid-scale-physical-double-scaled', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="without double-scaling physical jambs" ' + 'test/opening-symbol.test.mjs', because: 'wall-face depth is already converted from centimetres to render units; applying ' + 'the visual factor again makes physical geometry five times too large (#239)', patches: [{ file: 'src/render/opening-symbol.ts', find: ' ? ((spec.face.cm / spec.cellCm) * spec.gridPitch) / 2', replace: ' ? (((spec.face.cm / spec.cellCm) * spec.gridPitch) / 2) ' + '* gridVisualScale(spec.cellCm)', }], }, { id: 'opening-symbol-flip-restores-edge-offset', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test test/opening-symbol-placement.test.mjs ' + 'test/opening-symbol.test.mjs test/iso-openings.test.mjs', because: 'a saved flip changes only opening direction; restoring the released #242 ' + 'door/window edge translation moves the symbol off the wall centreline (#250)', patches: [{ file: 'src/opening-symbol-placement.ts', find: ' return { ox: 0, oy: 0 };', replace: " if (_flipV && (_type === 'door' || _type === 'window'))\n" + " return { ox: 0, oy: Math.hypot(_face.ox, _face.oy) };\n" + ' return { ox: 0, oy: 0 };', }], }, { id: 'opening-gate-flip-cancels-turn', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="shared renderer centres every flip" ' + 'test/opening-symbol.test.mjs', because: 'restoring the second flip_v inversion makes shared and partition gates emit ' + 'the same first-leaf turn sign for both saved values even though their origin stays centred', patches: [{ file: 'src/render/opening-symbol.ts', find: ' const gateAngle = spec.face.side * 10 * amount;', replace: ' const gateAngle = spec.face.side * sy * 10 * amount;', }], }, { id: 'wall-identity-editor-commit-barrier-bypassed', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="structural transaction crosses" ' + 'test/wall-segment-model.test.mjs', because: 'a geometry writer that persists the mutable v7/v8 projection without rebuilding ' + 'stable ids can detach thickness and opening hosts during any later editor operation (#282)', patches: [{ file: 'src/houseplan-card.ts', find: ' } else committedCandidate = commitWallSegmentModel(liveCandidate).config;', replace: ' } else committedCandidate = liveCandidate;', }], }, { id: 'wall-identity-history-restore-barrier-bypassed', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="structural transaction crosses" ' + 'test/wall-segment-model.test.mjs', because: 'Undo and Redo are a separate structural writer family; restoring a snapshot ' + 'without rebuilding v8 references can persist stale room and opening identities (#282)', patches: [{ file: 'src/houseplan-card.ts', find: ' committedCandidate = commitWallSegmentModel(restoredCandidate).config;', replace: ' committedCandidate = restoredCandidate;', }], }, { id: 'wall-identity-optimize-barrier-bypassed', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="structural transaction crosses" ' + 'test/wall-segment-model.test.mjs', because: 'Optimize repairs geometry outside the interactive editor; omitting its identity ' + 'barrier would leave the authoritative catalogue behind the repaired projections (#282)', patches: [{ file: 'src/plan-optimizer.ts', find: ' const wallCommit = commitWallSegmentModelInPlace(config);', replace: ' const wallCommit = { migratedSegments: 0, migratedDrafts: 0, migratedDraftSegments: 0 };', }], }, { id: 'current-rejected-physical-write-keeps-optimistic-wall', guard: 'node demo/smoke_v8_draft_write.mjs', because: 'a rejected config/set must synchronously discard its whole pending physical batch ' + 'before an optimistic active-chain partition survives as a ghost wall (#314/#478)', patches: [{ file: 'src/houseplan-card.ts', find: ' const rolledBack = this._rollbackRejectedPhysicalWrites(strictEntries);', replace: ' const rolledBack = false;', }], }, { id: 'area-relocation-loses-position-on-refusal', guard: 'node demo/smoke_area_relocation_safety.mjs', because: 'a rejected provenance write must restore every manual layout point deleted ' + 'before config/set, or leave explicit attention when restoration also fails (#403)', patches: [{ file: 'src/houseplan-card.ts', find: ' await this._persistDevicePlacement(id, placement);', replace: ' void id;\n void placement;', }], }, { id: 'area-relocation-clears-whole-history', guard: 'node demo/smoke_area_relocation_safety.mjs', because: 'moving one marker to a new HA Area must invalidate only that marker\'s commands ' + 'instead of silently erasing Undo and Redo for every other marker (#403)', patches: [{ file: 'src/houseplan-card.ts', find: ' const relocating = this._areaRelocationIds;\n' + ' this._devicePositionHistory.removeWhere(({ before, after }) =>\n' + ' relocating.has(before.deviceId) || relocating.has(after.deviceId));', replace: ' this._devicePositionHistory.clear();', }], }, { id: 'area-cleanup-keeps-candidate-outside-current-snapshot', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + '&& node --test --test-name-pattern="absent from the current snapshot" ' + 'test/device-area-relocation.test.mjs', because: 'finite two-frame evidence belongs to one current snapshot binding; retaining a ' + 'candidate after that binding was removed can later delete unrelated provenance (#434 AC8)', patches: [{ file: 'src/device-area-relocation.ts', find: ' if (validBinding(binding) && Number.isFinite(revision) && snapshotBindings.has(binding)) {', replace: ' if (validBinding(binding) && Number.isFinite(revision)) {', }], }, { id: 'support-stale-preview-response-revives-consent', guard: 'node demo/smoke_support_feedback.mjs', because: 'a late preview response must not restore exact plan geometry after attachment ' + 'consent was revoked or a newer preview generation won (#418)', patches: [{ file: 'src/houseplan-editor-runtime.ts', find: " return generation === this._supportPreviewGeneration\n" + ' && current?.draftId === draftId\n' + ' && current.attach;', replace: ' return true;', }], }, { id: 'support-invalid-response-leaks-issued-token', guard: 'node demo/smoke_support_feedback.mjs', because: 'a syntactically valid token is already an allocated backend slot even when the ' + 'rest of the preview response is invalid; it must be discarded independently of UI ' + 'currentness instead of lingering until TTL (#434 AC10)', patches: [{ file: 'src/houseplan-editor-runtime.ts', find: ' if (issuedToken) {\n' + ' const token = issuedToken;\n' + " issuedToken = '';\n" + ' void this._discardSupportPreview(token);\n' + ' }', replace: ' void issuedToken;', }], }, { id: 'support-edited-retry-reuses-old-idempotency-key', guard: 'node demo/smoke_support_feedback.mjs', because: 'a retry with changed outbound message, contact or preview must receive a new ' + 'idempotency key instead of resolving to the first payload at the relay (#418)', patches: [{ file: 'src/support-feedback.ts', find: ' idempotencyKey: state.submissionFingerprint\n' + ' && state.submissionFingerprint !== fingerprint\n' + " ? randomId('report')\n" + ' : state.idempotencyKey,', replace: ' idempotencyKey: state.idempotencyKey,', }], }, { id: 'support-timeout-claims-success', guard: 'node demo/smoke_support_feedback.mjs', because: 'a relay timeout, rate limit or unknown command must preserve the draft and expose ' + 'manual recovery; claiming success loses the report while telling the user it was sent (#43)', patches: [{ file: 'src/houseplan-editor-runtime.ts', find: " } catch (error: unknown) {\n" + " if (!this._supportPatch(current.draftId, {\n" + " status: 'error',\n" + ' errorCode: supportErrorCode(error),', replace: " } catch (error: unknown) {\n" + " if (!this._supportPatch(current.draftId, {\n" + " status: 'success',\n" + " reportId: 'HP-FALSE-SUCCESS',\n" + " errorCode: '',", }], }, { id: 'decor-raster-full-decode-skipped', guard: 'python3 -m pytest tests_backend/test_decor_assets.py -q -p no:cacheprovider', because: 'a valid-looking PNG whose IDAT is not a zlib stream must be refused before it ' + 'enters the authenticated store; header parsing answers w/h/mime and cannot answer ' + 'whether the raster decodes at all (#51 AC, аудит #430 п.1)', patches: [{ file: 'custom_components/houseplan/decor_assets.py', find: ' with Image.open(BytesIO(data)) as image:\n' + ' image.load()\n' + ' if image.size != (width, height):\n' + ' raise DecorAssetError("invalid_image", "Image dimensions are inconsistent")\n' + ' if getattr(image, "is_animated", False):\n' + ' raise DecorAssetError("unsupported_image", "Animated images are unsupported")', replace: ' _ = (Image, BytesIO)', }], }, { id: 'decor-physical-inventory-follows-sidecars', guard: 'node scripts/backend-test-guard.mjs physical_inventory ' + 'tests_backend/test_decor_assets.py', because: 'quota is a physical-storage boundary: malformed/missing sidecars cannot make ' + 'promoted blobs invisible, while sidecars without blobs consume no blob quota (#434 AC1)', patches: [{ file: 'custom_components/houseplan/decor_assets.py', find: ' entries = list(root.iterdir())\n', replace: ' entries = list(root.glob("*.json"))\n', }], }, { id: 'decor-catalog-accepts-sidecar-without-blob', guard: 'python3 -m pytest tests_backend/test_decor_assets.py -q -p no:cacheprovider ' + '-k valid_shaped_sidecar_without_blob', because: 'catalog/list/resolve remain strict projections; a valid-looking sidecar must not ' + 'materialise an image whose matching blob is absent (#434 AC4)', patches: [{ file: 'custom_components/houseplan/decor_assets.py', find: ' or not blob.is_file()\n', replace: '', }], }, { id: 'decor-orphan-repair-runs-after-quota', guard: 'python3 -m pytest tests_backend/test_ha_websocket.py -q -p no:cacheprovider ' + '-k decor_asset_upload_deduplicates', because: 'an exact digest-proven orphan adds no physical bytes and must repair its sidecar ' + 'even when physical count quota is already full (#434 AC2)', patches: [{ file: 'custom_components/houseplan/http_api.py', find: ' if blob.exists():\n' + ' if not blob.is_file() or hashlib.sha256(blob.read_bytes()).hexdigest() != aid:', replace: ' if False and blob.exists():\n' + ' if not blob.is_file() or hashlib.sha256(blob.read_bytes()).hexdigest() != aid:', }], }, { id: 'decor-orphan-repair-claims-reuse', guard: 'python3 -m pytest tests_backend/test_ha_websocket.py -q -p no:cacheprovider ' + '-k decor_asset_upload_deduplicates', because: 'reused:true means a valid catalog entry predated the request; rebuilding a lost or ' + 'broken sidecar creates that entry and must report false (#434 AC2)', patches: [{ file: 'custom_components/houseplan/http_api.py', find: ' os.replace(meta_temp, meta)\n' + ' return row, False\n' + ' finally:', replace: ' os.replace(meta_temp, meta)\n' + ' return row, True\n' + ' finally:', }], }, { id: 'decor-delete-skips-orphan-blobs', guard: 'python3 -m pytest tests_backend/test_ha_websocket.py -q -p no:cacheprovider ' + '-k decor_asset_delete_removes_exact_orphans_only', because: 'explicit delete is the only bounded recovery path for invisible orphan blobs; ' + 'depending on catalog metadata leaves quota permanently occupied (#434 AC3)', patches: [{ file: 'custom_components/houseplan/websocket_api.py', find: ' for extension in ASSET_EXTENSIONS:\n', replace: ' for extension in ():\n', }], }, { id: 'decor-svg-canonical-bytes-discarded', guard: 'python3 -m pytest tests_backend/test_decor_assets.py -q -p no:cacheprovider', because: 'the stored SVG must be the re-serialised canonical form, not the upload: keeping ' + 'the original bytes silently reinstates whatever the parser dropped — prologue, ' + 'comments, exotic spelling of the same tree (#51 ТЗ §3, аудит #430 п.2)', patches: [{ file: 'custom_components/houseplan/decor_assets.py', find: 'return ValidatedAsset(canonical, "image/svg+xml"', replace: 'return ValidatedAsset(data, "image/svg+xml"', }], }, { id: 'decor-svg-canonical-size-unchecked', guard: 'python3 -m pytest tests_backend/test_decor_assets.py -q -p no:cacheprovider', because: 'canonicalisation can grow the document fourfold by escaping text, so the 2 MiB ' + 'limit must be re-applied to the canonical bytes: a 1.84 MiB upload otherwise lands ' + 'as 7.35 MiB in the store (аудит #430 п.2)', patches: [{ file: 'custom_components/houseplan/decor_assets.py', find: ' _check_size(canonical)\n', replace: '', }], }, { id: 'decor-svg-external-url-guard-off', guard: 'python3 -m pytest tests_backend/test_decor_assets.py -q -p no:cacheprovider', because: 'javascript:, data:, http:, https: and // inside an allowed attribute of an allowed ' + 'tag are caught by this rule alone; every "external" case of the original corpus was ' + 'caught by tag or attribute allowlists instead (аудит #430 п.3)', patches: [{ file: 'custom_components/houseplan/decor_assets.py', find: 'if any(token in low for token in ("javascript:", "data:", "http:", "https:", "//")):', replace: 'if False:', }], }, { id: 'decor-image-flip-v-ignored', guard: 'node --test test/decor-assets.test.mjs', because: 'vertical flip is half of the image projection contract and had no witness of its ' + 'own: the single #51 case set flip_h only, so dropping flip_v stayed green (#51 AC3, ' + 'аудит #430 п.4)', patches: [{ file: 'src/decor-assets.ts', find: '${shape.flip_v ? -1 : 1}', replace: '1', }], }, { id: 'decor-image-opacity-ignored', guard: 'node --test test/decor-assets.test.mjs', because: 'the projection must carry the shape opacity; the only case asserted opacity 2 → 1, ' + 'an expectation indistinguishable from hardcoding 1 (#51 AC4, аудит #430 п.4)', patches: [{ file: 'src/decor-assets.ts', find: 'const opacity = clamp01(shape.opacity, 1);', replace: 'const opacity = 1;', }], }, { id: 'decor-asset-id-shape-unchecked', guard: 'node --test test/decor-assets.test.mjs', because: 'the catalog row must prove its own asset_id shape: the malformed row of #51 kept ' + 'the url of a real asset, so the url comparison caught it and the id regex could be ' + 'deleted unnoticed (аудит #430 п.4)', patches: [{ file: 'src/decor-assets.ts', find: " if (!DECOR_ASSET_ID_RE.test(String(row.asset_id || '')) || row.url !== expectedUrl", replace: ' if (row.url !== expectedUrl', }], }, { id: 'asset-resolve-readonly-membership-removed', guard: 'node scripts/backend-test-guard.mjs ' + 'decor_asset_resolve_readonly_is_limited_to_referenced_ids ' + 'tests_backend/test_ha_websocket.py', because: 'a read-only household member needs referenced images for View but must not use ' + 'assets/resolve to probe or hash arbitrary catalog ids (#432 AC2)', patches: [{ file: 'custom_components/houseplan/websocket_api.py', find: ' allowed = requested & referenced\n', replace: ' allowed = requested\n', }], }, { id: 'asset-integrity-cache-hit-disabled', guard: 'node scripts/backend-test-guard.mjs ' + 'integrity_cache_reuses_digest_and_caches_corrupt_signature ' + 'tests_backend/test_decor_assets.py', because: 'unchanged valid and corrupt files must reuse the actual digest instead of ' + 're-reading the blob for every WS resolve or HTTP GET (#432 AC5)', patches: [{ file: 'custom_components/houseplan/asset_integrity.py', find: ' if cached is not None and cached.signature == before:\n', replace: ' if False and cached is not None and cached.signature == before:\n', }], }, { id: 'asset-integrity-single-flight-disabled', guard: 'node scripts/backend-test-guard.mjs ' + 'integrity_cache_single_flights_same_path_and_releases_after_error ' + 'tests_backend/test_decor_assets.py', because: 'parallel requests for one file version must share one streaming hash and wake ' + 'all waiters after success or failure (#432 AC6)', patches: [{ file: 'custom_components/houseplan/asset_integrity.py', find: ' flight = self._inflight.get(key)\n', replace: ' flight = None\n', }], }, { id: 'asset-integrity-post-read-signature-ignored', guard: 'node scripts/backend-test-guard.mjs ' + 'integrity_cache_invalidates_changed_signature_and_rejects_mid_read_change ' + 'tests_backend/test_decor_assets.py', because: 'a digest computed while the blob changes must fail dark and never become a ' + 'trusted cache entry for either transport (#432 AC7)', patches: [{ file: 'custom_components/houseplan/asset_integrity.py', find: ' stable = _signature(path) == before\n', replace: ' stable = True\n', }], }, { id: 'asset-integrity-non-regular-file-admitted', guard: 'node scripts/backend-test-guard.mjs ' + 'integrity_verifier_rejects_non_regular_files_before_hashing ' + 'tests_backend/test_decor_assets.py', because: 'a FIFO or device with a valid asset filename can block an HA executor forever; ' + 'only regular files may reach the streaming hasher (#440 AC1)', patches: [{ file: 'custom_components/houseplan/asset_integrity.py', find: ' if not stat.S_ISREG(current.st_mode):\n' + ' raise OSError("asset is not a regular file")\n', replace: '', }], }, { id: 'asset-integrity-follower-waits-forever', guard: 'node scripts/backend-test-guard.mjs integrity_follower_has_a_bounded_wait ' + 'tests_backend/test_decor_assets.py', because: 'a follower must fail dark after a bounded wait even if an injected or wedged ' + 'owner never signals its single-flight event (#440 AC1)', patches: [{ file: 'custom_components/houseplan/asset_integrity.py', find: ' if not flight.event.wait(ASSET_INTEGRITY_FOLLOWER_TIMEOUT_SECONDS):\n', replace: ' if not flight.event.wait():\n', }], }, { id: 'room-tooltip-off-skips-pointer-modality', guard: 'node demo/smoke_room_tooltip_toggle.mjs', because: 'pen/touch pointermove inside an already hovered room must clear mouse-only hover ' + 'even when the room information tooltip is disabled (#440 AC2)', patches: [{ file: 'src/houseplan-card.ts', find: ' this._notePointer(ev);\n' + ' if (showRoomTooltipOf(this._settings)) return true;', replace: ' if (showRoomTooltipOf(this._settings)) return true;', }], }, { id: 'decor-upload-invalid-image-reported-as-capacity', guard: 'node scripts/backend-test-guard.mjs ' + 'decor_asset_upload_deduplicates_and_rejects_mime_spoofing ' + 'tests_backend/test_ha_websocket.py', because: 'a corrupt orphan is a client/integrity rejection, not exhausted storage; only ' + 'capacity_exceeded may produce HTTP 507 (#440 AC4)', patches: [{ file: 'custom_components/houseplan/http_api.py', find: ' status = (\n' + ' 507 if err.code == "capacity_exceeded"\n' + ' else 413 if err.code == "too_large"\n' + ' else 400\n' + ' )\n', replace: ' status = 507\n', }], }, { id: 'initial-view-ceiling-unplugged', guard: 'node --test test/bundle-assets.test.mjs', because: 'the initial-View ceiling must be ENFORCED by the CLI, not merely declared: this ' + 'gate has been silently removable twice (#429 took the old ratchet out on the very ' + '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' + ' if (ceiling) throw new Error(ceiling.text);\n', replace: '', }], }, { id: 'decor-oversize-hides-the-downscale-action', guard: 'node --test test/decor-image-upload.test.mjs', because: 'a decor source above the 2 MiB asset limit must still be offered as a reduced ' + 'copy: the flag forbids keeping the ORIGINAL, and gating the whole action block on it ' + 'is #427 — a bug that survived four review rounds because this path had no test at all', patches: [{ file: 'src/backdrop-pick.ts', find: ' ${hard ? null : html`', replace: ' ${hard || !allowOriginal ? null : html`', }], }, { id: 'decor-upload-loses-the-replace-flag', guard: 'node --test test/decor-image-upload.test.mjs', because: 'the guard dialog must upload into whichever slot the caller asked for: losing the ' + 'replaceSelection flag silently turns "replace this image" into "arm the palette" and ' + 'leaves the selected shape pointing at the old asset (#51 AC1, #433)', patches: [{ file: 'src/decor-image-editor.ts', find: ' this.hooks.setGuardReplace(replaceSelection);', replace: ' this.hooks.setGuardReplace(false);', }], }, { id: 'decor-upload-error-codes-collapse', guard: 'node --test test/decor-image-upload.test.mjs', because: 'each backend refusal code carries its own message: collapsing too_large into the ' + 'generic io_error tells the user "something went wrong" where the product knows exactly ' + 'what went wrong and how to fix it (#433)', patches: [{ file: 'src/decor-image-editor.ts', find: " too_large: 'backdrop.too_large_title',\n", replace: '', }], }, { id: 'pure-backend-test-pulls-home-assistant', guard: 'python3 -m pytest tests_backend/test_backend_quality.py -q -p no:cacheprovider', because: 'a pure test module that imports an HA-dependent backend module must be caught ' + 'statically: pytest aborts on collection, so ALL pure tests stop running and the ' + 'output looks nothing like a normal failure (#436, the #389 pattern)', patches: [{ file: 'tests_backend/test_projection.py', find: 'import copy\nimport importlib.util\nimport os', replace: 'import copy\nimport importlib.util\nimport os\n' + 'from custom_components.houseplan.store import async_save_config_state', }], }, { id: 'benchmark-page-verdict-unwatched', guard: 'node demo/guard/verify-guard.mjs', because: 'the page benchmark of #423 must register its page with watchPage, and that must be ' + 'proven by running it: the previous proof asked a regexp whether it still finds the ' + 'substring the same test had just deleted (аудит #430 п.5)', patches: [{ file: 'demo/benchmark_backdrop_decode.mjs', find: 'const page = watchPage(await (await browser.newContext()).newPage());', replace: 'const page = await (await browser.newContext()).newPage();', }], }, { id: 'marker-reject-keeps-optimistic-candidate', guard: 'node demo/smoke_marker_write_rollback.mjs', because: 'a rejected semantic marker write must restore accepted config and View while ' + 'leaving the independent Device editor draft available for Retry (#442 AC1)', patches: [{ file: 'src/houseplan-editor-runtime.ts', find: ' this.host._rollbackOptimistic(attempt);\n' + " this.host._regSignature = '';", replace: " this.host._regSignature = '';", }], }, { id: 'marker-rollback-keeps-enqueue-time-revision', guard: 'node demo/smoke_marker_write_rollback.mjs', because: 'a marker save queued behind an accepted write must guard rollback with the revision ' + 'its own request used, while still yielding to genuinely newer content (#442 AC2)', patches: [{ file: 'src/houseplan-editor-runtime.ts', find: ' if (attempt && liveFingerprint === attempt.attemptedFingerprint) Object.assign(attempt, { revision: this.host._cfgRev, attempted: candidate, attemptedFingerprint: candidateFingerprint });', replace: '', }], }, { id: 'accepted-marker-rolled-back-by-layout-failure', guard: 'node demo/smoke_marker_write_rollback.mjs', because: 'config acceptance is the durable marker boundary: a later layout failure must not ' + 'restore the marker that the server already accepted (#442 AC3)', patches: [{ file: 'src/houseplan-editor-runtime.ts', find: ' if (!configAccepted && attempt) {\n' + ' this.host._rollbackOptimistic(attempt);', replace: ' if (attempt) {\n' + ' this.host._serverCfg = attempt.previous;', }], }, { id: 'vacuum-reject-keeps-optimistic-matrix', guard: 'node --test test/vacuum-calibration-write.test.mjs', because: 'a rejected calibration, including first use, must not leave its matrix or synthetic ' + 'marker in the local accepted config (#442 AC5–AC7)', patches: [{ file: 'src/vacuum-calibration-write.ts', find: ' host._rollbackOptimistic(attempt);\n rebuild(host);', replace: ' rebuild(host);', }], }, { id: 'vacuum-auto-reports-success-before-acceptance', guard: 'node --test test/vacuum-calibration-write.test.mjs', because: 'automatic calibration must stay busy and suppress both duplicate writes and success ' + 'until config/set has actually resolved (#442 AC4)', patches: [{ file: 'src/vacuum-calibration-write.ts', find: ' const saved = await saveVacuumMatrix(\n' + ' runtime, request.markerId, request.source, request.mapId, request.matrix, request.routeId,\n' + ' );', replace: ' const saved = true;\n' + ' void saveVacuumMatrix(\n' + ' runtime, request.markerId, request.source, request.mapId, request.matrix, request.routeId,\n' + ' );', }], }, { id: 'optimistic-rollback-skips-same-root-fingerprint', guard: 'node --test test/config-adoption.test.mjs', because: 'newer in-place content can retain the attempted object identity; rollback must still ' + 'compare content and must never erase that newer owner (#442 AC2; owner moved by #500)', patches: [{ file: 'src/config-adoption.ts', find: ' if (!current || this.configRev !== attempt.revision\n' + ' || contentFingerprint(current) !== attempt.attemptedFingerprint) return false;', replace: ' if (!current || this.configRev !== attempt.revision\n' + ' || (current !== attempt.attempted\n' + ' && contentFingerprint(current) !== attempt.attemptedFingerprint)) return false;', }], }, { id: 'zigbee-topology-overlay-layer-lowered', guard: 'node demo/smoke_zigbee_topology_hover.mjs', because: 'the active topology must paint above unrelated markers and room labels while ' + 'remaining inside the existing plan camera context (#464 AC1)', patches: [{ file: 'src/hp-zigbee-topology-overlay.ts', find: ':host { position: absolute; inset: 0; z-index: 7; display: block; pointer-events: none; }', replace: ':host { position: absolute; inset: 0; z-index: 1; display: block; pointer-events: none; }', }], }, { id: 'zigbee-topology-endpoint-elevation-removed', guard: 'node demo/smoke_zigbee_topology_hover.mjs', because: 'complete source and drawable-neighbour marker roots must stay above every active ' + 'topology primitive rather than being crossed by their own link (#464 AC1)', patches: [{ file: 'src/styles/devices.styles.ts', find: '.dev[data-hp-zigbee-topology-endpoint] { z-index: 8; }', replace: '.dev[data-hp-zigbee-topology-endpoint] { z-index: 1; }', }], }, { id: 'zigbee-topology-hovered-endpoint-elevation-removed', guard: 'node demo/smoke_zigbee_topology_hover.mjs', because: 'semantic device hover has higher specificity than the base endpoint rule; the source ' + 'must still remain above its active topology line (#464 AC1, code-review r1)', patches: [{ file: 'src/styles/devices.styles.ts', find: ':host([data-pointer-hover]) .dev[data-hp-zigbee-topology-endpoint][data-hp-device-hover] { z-index: 8; }', replace: ':host([data-pointer-hover]) .dev[data-hp-zigbee-topology-endpoint][data-hp-device-hover] { z-index: 1; }', }], }, { id: 'zigbee-topology-unrelated-markers-raised', guard: 'node demo/smoke_zigbee_topology_hover.mjs', because: 'endpoint ownership is the exact rendered local link set; promoting every marker ' + 'would restore the original occlusion behind unrelated devices (#464 AC1, AC2)', patches: [{ file: 'src/hp-zigbee-topology-overlay.ts', find: ' this._setDesiredEndpointIds(lines.length || bubbles.length || hover.remoteCount\n' + ' ? [\n' + ' this._hovered,\n' + ' ...lines.map((line) => line.neighborMarkerId),\n' + ' ] : []);', replace: ' this._setDesiredEndpointIds(lines.length || bubbles.length || hover.remoteCount\n' + ' ? this.devices.filter((item) => item.space === this.currentSpace).map((item) => item.id) : []);', }], }, { id: 'zigbee-topology-endpoint-cleanup-skipped', guard: 'node demo/smoke_zigbee_topology_hover.mjs', because: 'transient endpoint ownership must not survive leave, modality/mode changes or ' + 'overlay disconnect and alter later marker stacking (#464 AC2)', patches: [{ file: 'src/hp-zigbee-topology-overlay.ts', find: ' private _clearEndpointOwnership(): void {\n' + ' for (const marker of this._endpointElements) marker.removeAttribute(ENDPOINT_ATTRIBUTE);\n' + ' for (const marker of this._parent?.querySelectorAll(`[${ENDPOINT_ATTRIBUTE}]`) || []) {\n' + ' marker.removeAttribute(ENDPOINT_ATTRIBUTE);\n' + ' }\n' + ' this._endpointElements.clear();\n' + ' }', replace: ' private _clearEndpointOwnership(): void {\n' + ' this._endpointElements.clear();\n' + ' }', }], }, { id: 'zigbee-topology-overlay-double-live-projection', guard: 'node demo/smoke_zigbee_topology_hover.mjs', because: 'a topology nested under the projected device layer must not receive a second ' + 'camera transform and drift away during pan or zoom (#464 AC2)', patches: [{ file: 'src/zigbee-topology-overlay-bridge.ts', find: ' return html`