",
replace: "
",
}],
},
{
id: 'marker-badge-position-forgets-touch',
guard: 'node demo/smoke_device_settings_form.mjs',
because: '#600 К1: сегмент стороны бейджа заменил select и обязан, как и он, ставить '
+ 'valueBadgeTouched — иначе выбор человека проигрывает проекции превью при следующем '
+ 'рендере и в конфиг не попадает',
patches: [{
file: 'src/editors/marker-dialog.ts',
find: " valueBadgePosition: position,\n valueBadgeTouched: true,",
replace: " valueBadgePosition: position,\n valueBadgeTouched: false,",
}],
},
{
id: 'form-kit-segment-breaks-words',
guard: 'node demo/smoke_dialog_segments_i18n.mjs',
because: '#600 ревью r1 M1: пять равных колонок сегмента с overflow-wrap: anywhere рвали '
+ '«Temperature» посреди слова, и ни один смок этого не видел — scrollWidth контейнера не '
+ 'рос. Колонка не сжимается ниже самого длинного слова, слова переносятся только по '
+ 'пробелам; вернуть прежние две строки — вернуть дефект в четырёх локалях',
patches: [{
file: 'src/styles/form-kit.styles.ts',
find: ' min-width: fit-content;\n min-height: 38px;\n flex: 1 1 0;',
replace: ' min-height: 38px;\n flex: 1 1 0;',
}, {
file: 'src/styles/form-kit.styles.ts',
find: ' text-align: center;\n overflow-wrap: normal;\n }',
replace: ' text-align: center;\n overflow-wrap: anywhere;\n }',
}],
},
{
id: 'form-kit-segment-drops-radio-semantics',
guard: 'node --test test/form-kit.test.mjs',
because: '#594 К7: сегментированный переключатель заменил радиосписок, и вся его '
+ 'доступность держится на том, что под капотом осталась радиогруппа. Подмена на '
+ 'чекбоксы выглядит идентично и молча ломает стрелки и объявление скринридером',
patches: [{
file: 'src/editors/form-kit.ts',
// #600: у радиокнопки сегмента появился value — якорь дописан.
find: ' [id, fill && fill.opacity > 0 ? fill : glowBase.byId.get(id) ?? null])))',
replace: "memoIsoLightFloorRooms(null, new Map([...roomFills.byId].map(([id, fill]) => [id, fill && fill.opacity > 0 ? fill : glowBase.byId.get(id) ?? null])), parseCssColor(getComputedStyle(this.renderRoot.querySelector('.hp-paper') ?? this).fill) ?? [255, 255, 255]).rooms",
}],
},
{
id: 'iso-first-frame-reveals-flat-during-lazy-load',
guard: 'node demo/smoke_iso_first_frame.mjs',
because: '#654 AC1/AC3: removing the pending class exposes the effective Flat projection '
+ 'while the 2.5D chunk is still loading, most visibly in kiosk mode after the ordinary '
+ 'boot window has elapsed',
patches: [{
file: 'src/houseplan-card.ts',
find: "${isoFirstFramePending ? ' hpiso-pending' : ''}",
replace: "${isoFirstFramePending ? '' : ''}",
}],
},
{
id: 'iso-light-floor-memo-never-reuses',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="#654 light-floor memo" test/iso-stage6.test.mjs',
because: '#654 AC4: HA state churn that leaves paper, fills and room membership unchanged '
+ 'must reuse the classification Set instead of rebuilding it on every card render',
patches: [{
file: 'src/iso-materials.ts',
find: ' return previous?.key === key',
replace: " return previous?.key === '__never__'",
}],
},
{
id: 'shutdown-skips-deferred-store-flush',
guard: 'node scripts/backend-test-guard.mjs '
+ 'home_assistant_stop_flushes_pending_virtual_light_and_trail '
+ 'tests_backend/test_ha_virtual_lights.py',
because: '#655 AC1/AC2: the HA stop event must synchronously flush both deferred stores; '
+ 'otherwise the latest virtual-light toggle and vacuum-trail points disappear on restart',
patches: [{
file: 'custom_components/houseplan/__init__.py',
find: ' await _async_flush_runtime(hass, entry)\n\n stop_listener = [',
replace: ' return # mutant: shutdown drops both pending stores\n\n stop_listener = [',
}],
},
{
id: 'virtual-light-save-bypasses-ha-task-tracking',
guard: 'node scripts/backend-test-guard.mjs '
+ 'runtime_controller_coalesces_rapid_toggles '
+ 'tests_backend/test_virtual_lights.py',
because: '#655 AC4: HA must own the delayed writer so normal shutdown waits for it; a raw '
+ 'asyncio task can be cancelled before its 0.5 second debounce reaches durable storage',
patches: [{
file: 'custom_components/houseplan/virtual_lights.py',
find: ' self._save_task = self.hass.async_create_task(self._delayed_save())\n',
replace: ' self._save_task = asyncio.create_task(self._delayed_save())\n',
}],
},
{
id: 'virtual-light-concurrent-flushes-bypass-lock',
guard: 'node scripts/backend-test-guard.mjs '
+ 'concurrent_flushes_after_failed_delayed_save_write_once '
+ 'tests_backend/test_virtual_lights.py',
because: '#655 AC3 / review r1: stop and config transitions may flush the same dirty '
+ 'post-failure state concurrently; the lock must keep the Store write idempotent',
patches: [{
file: 'custom_components/houseplan/virtual_lights.py',
find: ' async with self._flush_lock:\n',
replace: ' if True: # mutant: concurrent flushes are not serialised\n',
}],
},
// r1 #695: инфраструктура без трековой метки — трек show (§5.1).
{
id: 'guard-infra-keeps-ask-limit',
guard: 'node --test --test-name-pattern="r1 #695" test/review-doc-guard.test.mjs',
because: 'r1 #695: an infrastructure task without a track label reads as track:show (PROCESS §5.1); '
+ 'the guard must give it the show cycle limit 2, not the ask limit 4',
patches: [{
file: '.github/workflows/_process.yml',
find: ' limit=2\n echo "инфраструктурная задача без трековой метки',
replace: ' limit=4 # mutant: infra keeps the ask limit\n echo "инфраструктурная задача без трековой метки',
}],
},
{
id: 'packet-infra-track-ignores-show-default',
guard: 'node --test --test-name-pattern="r1 #695" test/task-packet.test.mjs',
because: 'r1 #695: the packet names the track an infrastructure task actually runs on — show '
+ 'without a label, the owner label otherwise',
patches: [{
file: 'scripts/task-packet.mjs',
find: " const infraTrack = hasTrackLabel(labels) ? trackFromLabels(labels) : 'show';",
replace: ' const infraTrack = trackFromLabels(labels); // mutant: unlabelled infra reads as ask',
}],
},
// #696: цена захода по треку — мутанты, рамки ship, пакетное ревью перед бетой.
{
id: 'track-pays-for-mutants-again',
guard: 'node --test --test-name-pattern="#709" test/process-track.test.mjs',
because: '#709: no track and no label buys a mutant run during development — the 28-minute '
+ 'run on ask and ci:mutants was the cost the owner removed',
patches: [{
file: 'scripts/process-track.mjs',
find: ' const mutants = false;',
replace: " const mutants = track === 'ask' || labels.includes('ci:mutants'); // mutant",
}],
},
{
id: 'ship-limits-miss-new-src-file',
guard: 'node --test --test-name-pattern="рамки ship: новые файлы" test/process-track.test.mjs',
because: '#696: a new file in src/** is outside ship limits — ship merges without a model '
+ 'review, so the mechanical limits are the only thing standing between it and dev',
patches: [{
file: 'scripts/process-track.mjs',
find: ' if (added.length) out.push(',
replace: ' if (false && added.length) out.push(',
}],
},
{
id: 'ship-limit-off-by-one',
guard: 'node --test --test-name-pattern="граница включительна" test/process-track.test.mjs',
because: '#696: the ship limit is 30 src lines inclusive (owner decision); 30 lines still ship',
patches: [{
file: 'scripts/process-track.mjs',
find: ' if (lines > SHIP_SRC_LINE_LIMIT) out.push(',
replace: ' if (lines >= SHIP_SRC_LINE_LIMIT) out.push(',
}],
},
{
id: 'pipeline-ship-ignores-limits',
guard: 'node --test --test-name-pattern="трек снимается до ребейза" test/process-track.test.mjs',
because: '#696: ship skips the model only inside the limits; ignoring the limits merges '
+ 'unread code of any size',
patches: [{
file: '.github/workflows/_process.yml',
find: " if printf '%s\\n' \"$limits\" | grep -qx 'ship=true'; then",
replace: ' if true; then # mutant: ship limits ignored',
}],
},
{
id: 'light-review-waits-running-push',
guard: 'node --test --test-name-pattern="#696" test/validate-gate.test.mjs',
because: '#696: a running push run cannot wake the round (process-resume listens to '
+ 'dispatch only); treating it as proof candidate parks the round forever',
patches: [{
file: 'scripts/validate-gate.mjs',
find: " return run?.event === 'workflow_dispatch' || (run?.event === 'push' && run?.status === 'completed');",
replace: " return run?.event === 'workflow_dispatch' || run?.event === 'push'; // mutant: running push counts",
}],
},
{
id: 'light-merge-dispatches-second-run',
guard: 'node --test --test-name-pattern="слияние ждёт push-прогон" test/merge-candidate.test.mjs',
because: '#696: the candidate push already starts the light Validate; a second dispatch '
+ 'doubles runner minutes on every show/ship merge',
patches: [{
file: 'scripts/merge-candidate.mjs',
find: " ({ result, url } = await ops.waitValidate(candidate, { event: 'push' }));",
replace: " ops.dispatchValidate(branch); ({ result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' })); // mutant: second run",
}],
},
{
id: 'ship-review-ignores-merge-marker',
guard: 'node --test --test-name-pattern="ship-задача — по маркеру" test/ship-review.test.mjs',
because: '#696: the pipeline marker proves a merge without model review even when the '
+ 'label was changed afterwards; missing it lets unread code into a beta',
patches: [{
file: 'scripts/ship-review.mjs',
find: " return names.includes('track:ship') || comments.some((c) => SHIP_MERGE_MARKER_RE.test(String(c?.body ?? '')));",
replace: " return names.includes('track:ship'); // mutant: marker ignored",
}],
},
{
id: 'ship-review-accepts-partial-coverage',
guard: 'node --test --test-name-pattern="машинный блок покрывает" test/ship-review.test.mjs',
because: '#696: a ship task merged after the batch review ran was read by nobody; the beta '
+ 'gate must refuse until the review is re-run',
patches: [{
file: 'scripts/ship-review.mjs',
find: ' if (missing.length) {',
replace: ' if (false && missing.length) {',
}],
},
{
id: 'ship-review-accepts-high',
guard: 'node --test --test-name-pattern="машинный блок покрывает" test/ship-review.test.mjs',
because: '#696: a High in the batch review is a defect a beta user would see; it blocks the beta',
patches: [{
file: 'scripts/ship-review.mjs',
find: ' else if (block.high > 0) problems.push(',
replace: ' else if (false && block.high > 0) problems.push(',
}],
},
// #697: производные артефакты — на dev, тяжёлое на ветке задачи — по меткам.
{
id: 'task-branch-release-trailer-heavy-again',
guard: 'node --test --test-name-pattern="#697" test/classify-changes.test.mjs',
because: '#697: on a task branch the Release: trailer only marks a baseline acceptance; '
+ 'it must not switch on smokes, golden and perf again — ci:full/ci:golden do that',
patches: [{
file: 'scripts/classify-changes.mjs',
find: ' if (isTaskBranch(refName)) return false;\n',
replace: '',
}],
},
{
id: 'ci-golden-label-does-not-order-full-set',
guard: 'node --test --test-name-pattern="#697" test/process-track.test.mjs',
because: '#697: a task that changes visuals on purpose sets ci:golden; without the full set '
+ 'on its review material its shifted frames surface in the next task again',
patches: [{
file: 'scripts/process-track.mjs',
find: " const full = labels.includes('ci:full') || labels.includes('ci:golden');",
replace: " const full = labels.includes('ci:full'); // mutant: ci:golden ignored",
}],
},
{
id: 'review-gate-accepts-light-proof-for-full',
guard: 'node --test --test-name-pattern="#697" test/validate-gate.test.mjs',
because: '#697: with ci:full/ci:golden a light push run proves nothing about smokes and golden; '
+ 'the gate must dispatch full=true instead of accepting it',
patches: [{
file: 'scripts/validate-gate.mjs',
find: " const policy = full ? Object.freeze({ ...base, name: `${base.name}-full`, full: true }) : base;",
replace: ' const policy = base; // mutant: full never required',
}],
},
{
id: 'bot-golden-commit-without-provenance',
guard: 'node --test --test-name-pattern="провенанс" test/beta-derived.test.mjs',
because: '#697: the bot commit that moves baselines must carry Release: and Baseline-Reviewed:, '
+ 'otherwise validate-commit-provenance turns dev red on the push',
patches: [{
file: '.github/workflows/beta-derived.yml',
find: ' echo "Release: $TAG"\n',
replace: '',
}],
},
];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
const mutationEditorSource = readFileSync(join(repoRoot, 'src/houseplan-editor-runtime.ts'), 'utf8');
// #659: these witnesses exercise pure state/geometry/CSS contracts and do not
// need Chromium. Keep the grouping explicit: every promoted mutant is proven
// by `mutation-gate`, not merely reclassified to avoid a bundle build.
const unitGuard = (...files) => `node --test ${files.join(' ')} test/mutation-browser-offload.test.mjs`;
const UNIT_GUARD_GROUPS = [
[unitGuard('test/stairs.test.mjs'), [
'stairs-fixed-floor-still-navigates',
'stairs-link-auto-creates-target-object',
'stairs-broken-targets-become-active',
]],
// #676: gesture-click suppression and the tooltip's `active` gate are template
// wiring; the box model beside them is pure and tested in stairs-box.test.mjs.
[unitGuard('test/stairs-box.test.mjs'), [
'stairs-gesture-click-reaches-plan-tool',
'stairs-view-tooltip-ignores-target-state',
]],
[unitGuard('test/device-hit-owner.test.mjs'), [
'device-hit-scroll-observer-disabled',
]],
[unitGuard('test/summary-panel-runtime.test.mjs'), [
'summary-hide-unmounts-before-animation',
'summary-first-paint-shows-unavailable',
'summary-metrics-block-first-frame',
]],
[unitGuard('test/form-shell-consumers.test.mjs'), [
'discard-confirm-action-icon-falls-back-to-lock',
]],
[unitGuard('test/form-kit.test.mjs'), [
'range-line-clamps-every-keystroke',
]],
[unitGuard('test/dialog-baseline.test.mjs', 'test/styles-split.test.mjs'), [
'ha-form-shell-width-falls-back-to-generic',
'ha-form-shell-loses-canvas',
'ha-form-shell-mobile-keeps-desktop-inset',
]],
[unitGuard('test/summary-panel.test.mjs'), [
'summary-dialog-loses-wide-shell',
'summary-mobile-ignores-local-off',
]],
[unitGuard('test/styles-split.test.mjs'), [
'marker-shadow-animates-again',
]],
[unitGuard('test/device-inbox.test.mjs'), [
'device-inbox-batch-single-write',
]],
[unitGuard('test/houseplan-panel.test.mjs'), [
'panel-ignores-pre-upgrade-properties',
'panel-host-height-from-parent',
]],
[unitGuard('test/version-recovery-card.test.mjs'), [
'version-recovery-delays-config-capability-adoption',
]],
[unitGuard('test/vacuum-calibration-write.test.mjs'), [
'vacuum-manual-fit-after-proposal-uses-the-dock',
]],
[unitGuard('test/devices.test.mjs'), [
'room-climate-ignores-marker-placement',
]],
[unitGuard('test/device-inbox.test.mjs'), [
'device-tombstone-blocks-child-picker',
]],
[unitGuard('test/room-fit.test.mjs'), [
'room-fit-interactive-owner-leaks-through',
]],
[unitGuard('test/iso-stage6.test.mjs', 'test/styles-split.test.mjs'), [
'iso-tile-ring-visible',
'iso-tile-edge-zero',
'iso-tile-badge-gap-dropped',
'iso-tile-shadow-layer-above',
'iso-frame-selected-beats-focus',
'iso-shadow-in-forced-colors',
'iso-sun-length-ignores-elevation',
'iso-sun-streaks-on-dark-floor',
'iso-wall-top-constant',
]],
[unitGuard('test/backdrop-probe.test.mjs'), [
'backdrop-hard-demoted-to-warn',
]],
[unitGuard('test/bundle-assets.test.mjs'), [
'css-minifier-eats-required-space',
]],
[unitGuard('test/wall-thickness.test.mjs', 'test/wall-union-isolation.test.mjs'), [
'wall-component-failure-kills-primary',
]],
[unitGuard('test/glow-scene.test.mjs'), [
'union-failure-silent',
'glow-fail-dark-weakened',
'opening-cut-degenerate',
'column-shadow-removed',
'feather-20px',
]],
[unitGuard('test/visual-continuity.test.mjs'), [
'continuity-long-resume-noop',
]],
[unitGuard('test/ha-binding-status.test.mjs'), [
'registryless-opening-requires-registry-row',
]],
[unitGuard('test/header-menu.test.mjs'), [
'header-menu-item-keeps-menu-open',
'header-menu-escape-ignored',
'active-tab-not-revealed',
]],
[unitGuard('test/iso-scene-render.test.mjs', 'test/iso-stage6.test.mjs'), [
'grid-scale-iso-height-unscaled',
'stage3-w8-material-defs-created-per-face',
]],
[unitGuard('test/support-feedback.test.mjs'), [
'support-edited-retry-reuses-old-idempotency-key',
]],
[unitGuard('test/touch-gesture-click-guard.test.mjs'), [
'touch-pinch-contextmenu-guard-removed',
]],
[unitGuard('test/live-editor.test.mjs'), [
'live-editor-devices-drops-align-guides',
'live-editor-decor-drops-align-guides',
'live-editor-plan-drops-align-guides',
'live-editor-keeps-the-settled-guides-visible',
]],
[unitGuard('test/live-viewport.test.mjs'), [
'live-pan-incoming-edge-clipped',
'live-pan-rewrites-viewbox-every-frame',
]],
[unitGuard('test/summary-runtime-loader.test.mjs'), [
'summary-runtime-attaches-after-first-render',
]],
[unitGuard('test/config-reload-authority.test.mjs'), [
'config-reload-drops-asset-gap-ownership',
]],
[unitGuard('test/summary-panel.test.mjs'), [
'summary-picker-renders-unbounded-results',
]],
[unitGuard('test/form-shell-consumers.test.mjs', 'test/dialog-baseline.test.mjs',
'test/space-dialog.test.mjs'), [
'same-binding-click-resets-source',
'state-callout-hidden-under-help',
'dialog-card-loses-its-heading',
'form-kit-writes-to-a-neighbour-key',
'space-fill-segment-writes-a-neighbour-key',
'space-save-enabled-without-changes',
'space-discard-without-asking',
'space-layer-toggle-loses-inversion',
'general-save-enabled-without-changes',
'general-tile-opacity-writes-a-neighbour-key',
'general-north-clear-writes-zero',
'topology-embedded-draws-its-own-heading',
'room-save-enabled-without-changes',
'room-discard-without-asking',
'room-fill-segment-starts-at-none',
'room-temp-min-writes-max',
'marker-save-enabled-without-changes',
'marker-save-click-forgets-baseline-before-result',
'marker-discard-without-asking',
'marker-confirm-row-shown-for-do-nothing',
'marker-never-keeps-glow-block-live',
'marker-badge-position-forgets-touch',
]],
[unitGuard('test/color-picker.test.mjs'), [
'M-615-tile',
'M-615-plate',
]],
[unitGuard('test/form-kit.test.mjs', 'test/styles-split.test.mjs'), [
'form-kit-segment-breaks-words',
]],
[unitGuard('test/room-gear-drag.test.mjs'), [
'room-gear-drag-reopens-settings',
]],
];
const UNIT_GUARD_OVERRIDES = new Map(UNIT_GUARD_GROUPS.flatMap(([guard, ids]) => (
ids.map((id) => [id, guard])
)));
export const MUTANTS = MUTANT_DEFINITIONS.map((mutant) => ({
...mutant,
guard: UNIT_GUARD_OVERRIDES.get(mutant.id) ?? mutant.guard,
patches: mutant.patches.map((patch) => relocateEditorPatch(
patch, mutationCardSource, mutationEditorSource,
)),
}));