mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
9726 lines
492 KiB
JavaScript
9726 lines
492 KiB
JavaScript
#!/usr/bin/env node
|
||
// Реестр известных поломок (mutation gate), issue #85.
|
||
//
|
||
// node scripts/mutation-gate.mjs --list мутанты и кого они проверяют
|
||
// node scripts/mutation-gate.mjs --check патчи применимы к текущему коду
|
||
// node scripts/mutation-gate.mjs полный прогон: все мутанты
|
||
// node scripts/mutation-gate.mjs --id=<mutant> один мутант
|
||
// node scripts/mutation-gate.mjs --build-only применить и собрать, тест не гонять
|
||
//
|
||
// Код выхода: 0 — каждый тест поймал свою поломку, 1 — хотя бы один не поймал,
|
||
// 2 — не смог проверить (патч не лёг, сборка упала).
|
||
//
|
||
// Зачем. Зелёный тест в этом проекте несколько раз означал «ничего не
|
||
// проверено», и выяснялось это после того, как баг доезжал до владельца:
|
||
// смок непрерывности не заметил удаления механизма, который защищает; golden,
|
||
// заведённый под #71, был пуст — 1 177 тёплых пикселей против 107 119, и все
|
||
// 1 177 были иконками. Общее у всех случаев: тест ни разу не проверяли на
|
||
// способность падать. Этот гейт делает такую проверку регулярной.
|
||
//
|
||
// Каждый мутант — маленький патч продуктового исходника, воспроизводящий
|
||
// известную поломку, и имя теста, который ОБЯЗАН на ней покраснеть. Прогон:
|
||
// worktree → патч → сборка бандла → бандл в demo/srv/assets → тест. Тест,
|
||
// оставшийся зелёным, — это провал гейта, а не успех теста.
|
||
//
|
||
// Прогон дорогой (пересборка бандла на мутанта) и проверяет тесты, а не
|
||
// продукт, поэтому его место — ночное расписание (.github/workflows/
|
||
// mutation-gate.yml, #513), вне цикла разработки и релиза; отказ — issue.
|
||
// Дешёвая часть — «патчи применимы, guard-файлы существуют» — живёт в
|
||
// test/mutation-gate.test.mjs и идёт с обычными юнитами: реестр, отставший от
|
||
// кода, хуже отсутствующего, потому что выглядит защитой.
|
||
|
||
import { spawnSync } from 'node:child_process';
|
||
import {
|
||
cpSync, existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync,
|
||
writeFileSync,
|
||
} from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { dirname, join } from 'node:path';
|
||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||
import { createHash } from 'node:crypto';
|
||
import { mkdirSync } from 'node:fs';
|
||
import { withoutProductVersion } from './source-fingerprint.mjs';
|
||
import { closure, trackedFiles } from './check-inputs.mjs';
|
||
|
||
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: '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-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: '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: '<ha-panel-custom> 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) {',
|
||
replace: ' if (false && !this._input.kiosk) {',
|
||
}],
|
||
},
|
||
{
|
||
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);',
|
||
replace: ' const sourcePartitions = [];',
|
||
}],
|
||
},
|
||
{
|
||
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: 'confirm-dialog-loses-alertdialog',
|
||
guard: 'node demo/smoke_danger_confirm_branches.mjs',
|
||
because: 'delete and unlock confirmations must expose the real dialog as an alertdialog '
|
||
+ 'with its consequence text in both standalone and HA environments (#406)',
|
||
patches: [{
|
||
file: 'src/hp-dialog.ts',
|
||
find: " role=${this.alert ? 'alertdialog' : 'dialog'}",
|
||
replace: ' role="dialog"',
|
||
}],
|
||
},
|
||
{
|
||
id: 'vacuum-route-ambiguity-takes-the-first',
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node --test test/vacuum-routes.test.mjs',
|
||
because: 'two plausible routes must give ambiguous, never "the first one": a guessed '
|
||
+ 'floor turns the plan into a false statement about where the robot is (#162, M-A)',
|
||
patches: [{
|
||
file: 'src/vacuum-routes.ts',
|
||
find: ' if (matched.length > 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;",
|
||
replace: " case 'нет такого':\n return resolution.kind as any;",
|
||
}],
|
||
},
|
||
{
|
||
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: ' <button type="button" class="cand opening-entity-candidate ${cur ? \'\' : \'sel\'}"\n'
|
||
+ ' data-opening-entity="" @click=${() => this._selectOpeningEntity(kind, \'\')}>\n'
|
||
+ ' <span class="cl">${this._t(\'opening.none\')}</span>\n'
|
||
+ ' </button>\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;',
|
||
}],
|
||
},
|
||
{
|
||
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',
|
||
find: " if (mode === 'fixed-topology') {\n const direct = wholeEdgeMoves.get(w.key);",
|
||
replace: " if (false && mode === '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'
|
||
+ ' );',
|
||
replace: ' const resolution: SafeResizeResolution = { enabled: true, plan: {} as SafeResizePlan };',
|
||
}],
|
||
},
|
||
{
|
||
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: '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(\n'
|
||
+ ' this, LANGUAGE_RUNTIME, 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, LANGUAGE_RUNTIME, 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-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: '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<string>) => new Set(\n seedHiddenBindings({ ...ctx, settings, excluded } as never));",
|
||
replace: " const seededOf = (settings: object, excluded: ReadonlySet<string>) => 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: [{
|
||
file: 'src/houseplan-editor-runtime.ts',
|
||
find: " if (c.value === d.binding) {\n this.host._markerDialog = { ...d, bindingOpen: false };\n return;\n }",
|
||
replace: " if (false) { return; }",
|
||
}],
|
||
},
|
||
{
|
||
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: '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-camera-mismatch',
|
||
guard: 'node --test test/furniture-stroke-contract.test.mjs',
|
||
because: 'the stroke compensation models the flat camera; applying it in the labs iso '
|
||
+ 'projection diverges furniture from ordinary decor by a wrong-camera factor (#376г)',
|
||
patches: [{
|
||
file: 'src/houseplan-card.ts',
|
||
find: " const furnitureScreenScale = this._renderProjection === 'iso' ? 1 : furniturePlanScreenScale(",
|
||
replace: " const furnitureScreenScale = furniturePlanScreenScale(",
|
||
}],
|
||
},
|
||
{
|
||
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: '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: '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-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 guard — the smoke counts BASELINE computations
|
||
// (exactly one per gesture with a working cache, one per pointermove
|
||
// without) — plus the source-contract unit stays as the cheap first line.
|
||
guard: 'node demo/smoke_junction_limits.mjs '
|
||
+ '&& npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node --test --test-name-pattern="#330 AC4" test/junction-limits.test.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] : [];',
|
||
replace: ' return [];',
|
||
}],
|
||
},
|
||
{
|
||
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; }",
|
||
}],
|
||
},
|
||
// #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',
|
||
find: ' if (!runMutant(entry.mutant)) ' + 'continue;\n caught++;',
|
||
replace: ' if (!runMutant(entry.mutant)) { if (ledger) recordCaught(ledgerArg, ledger, entry.mutant, entry.fingerprint); continue; }\n caught++;',
|
||
}, {
|
||
// Unit-наблюдаемая половина того же контракта: пустой отпечаток в
|
||
// записи считался бы «совпавшим» с любым — журнал перестаёт сравнивать.
|
||
file: 'scripts/mutation-gate.mjs',
|
||
find: " if (ledger.caught[mutant.id] === fingerprint) " + "skipped.push(mutant);",
|
||
replace: " if (ledger.caught[mutant.id] === fingerprint || 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'
|
||
+ ' }',
|
||
replace: " if (true) 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-gate.mjs',
|
||
// Реестр живёт в том же файле, что и код: якорь собирается из частей,
|
||
// иначе --check найдёт его дважды — в коде и здесь.
|
||
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-gate.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-gate.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-gate.mjs',
|
||
find: ' if (!hunks || !hunks.length)' + ' return true;',
|
||
replace: ' if (!hunks || !hunks.length) return false;',
|
||
}],
|
||
},
|
||
{
|
||
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-gate.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-gate.mjs',
|
||
find: " ledger.caught[mutant.id] = fingerprint;\n mkdirSync(dirname(file), " + "{ recursive: true });\n writeFileSync(file,",
|
||
replace: " ledger.caught[mutant.id] = fingerprint;\n mkdirSync(dirname(file), { recursive: true });\n if (Object.keys(ledger.caught).length > 1) writeFileSync(file,",
|
||
}],
|
||
},
|
||
{
|
||
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: '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-gate.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-gate.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-gate.mjs',
|
||
// Реестр живёт в том же файле, что и код: якорь собирается из двух
|
||
// частей, иначе --check найдёт его дважды — в коде и здесь.
|
||
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-gate.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: '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',
|
||
find: ' if (distToSeg(mid[0], mid[1], p0[0], p0[1], p1[0], p1[1]) <= eps) {',
|
||
replace: ' if (false) {',
|
||
}],
|
||
},
|
||
{
|
||
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',
|
||
replace: ' }\n return canonicalizeNumber(value);\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') {",
|
||
replace: " if (false && united.status === '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',
|
||
guard: 'node demo/smoke_optimize_geometry_preflight.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/houseplan-card.ts',
|
||
find: ' if (!d || d.busy || !this._serverCfg || !d.changed || !d.preflight?.ok) return;',
|
||
replace: ' if (!d || d.busy || !this._serverCfg || !d.changed) 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',
|
||
guard: 'node demo/smoke_optimize_geometry_preflight.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/houseplan-card.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',
|
||
guard: 'node demo/smoke_near_axis_optimize.mjs',
|
||
because: 'opening the Optimize preview or cancelling it must never persist a lossy repair (#290)',
|
||
patches: [{
|
||
file: 'src/houseplan-editor-runtime.ts',
|
||
find: 'public _openAlignDialog = (): void => this._previewAlignDialog(false);',
|
||
replace: 'public _openAlignDialog = (): void => {\n'
|
||
+ ' this._previewAlignDialog(false);\n'
|
||
+ ' void this._runAlignToGrid();\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);',
|
||
replace: ' if (!walls?.length) return [];\n 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-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: ' glowEnabled: false, allLightsOff: true, northDeg: 90,',
|
||
replace: ' 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: '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`<span class=\"backupplanonlystatus\">${this._t('backup.plan_only_preview')}</span>` : nothing}",
|
||
replace: "${false ? html`<span class=\"backupplanonlystatus\">${this._t('backup.plan_only_preview')}</span>` : 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`<g class="room-svg-labels" pointer-events="none">${space.rooms.map((room) => {\n'
|
||
+ ' const center = this._roomCenter(room);\n'
|
||
+ ' return svg`<text class="rlabel" data-hp="room-label" data-id=${room.id || nothing}\n'
|
||
+ ' data-area=${room.area || nothing} x=${center[0]} y=${center[1]}>${room.name}</text>`;\n'
|
||
+ ' })}</g>` : 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'
|
||
+ ' <g class="decorlayer" pointer-events="none">${decorImages}</g>\n'
|
||
+ ' ${glowPools}\n ${wallUnion',
|
||
replace: ' ${passageGlowTunnels}\n'
|
||
+ ' ${!space.bg && !disp.showNames ? svg`<g class="room-svg-labels" pointer-events="none">${space.rooms.map((room) => {\n'
|
||
+ ' const center = roomCenter(room);\n'
|
||
+ ' return svg`<text class="rlabel" data-hp="room-label" data-id=${room.id || nothing}\n'
|
||
+ ' data-area=${room.area || nothing} x=${center[0]} y=${center[1]}>${room.name}</text>`;\n'
|
||
+ ' })}</g>` : nothing}\n'
|
||
+ ' <g class="decorlayer" pointer-events="none">${decorImages}</g>\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):hover {',
|
||
replace: '.dev.unavail: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',
|
||
find: ' } else if (staticIcon) {\n visual = NEUTRAL_VISUAL;',
|
||
replace: ' } else if (false && staticIcon) {\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',
|
||
find: " const face: PresentationFace = input.display === 'value'\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',
|
||
find: ' const metrics = !staticIcon && !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):hover {',
|
||
replace: ' .dev:not(.unavail):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: диалог обязан называть причину отказа по каждому пространству.
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node demo/smoke_preflight_diagnostics.mjs',
|
||
because: 'отказ preflight без причины недиагностируем — ровно исходный дефект #295',
|
||
patches: [{
|
||
file: 'src/houseplan-editor-runtime.ts',
|
||
find: " ${failure.displayName}: ${this.host._t(`gs.preflight_reason_${failure.reason}` as I18nKey)}",
|
||
replace: " ${failure.displayName}",
|
||
}],
|
||
},
|
||
{
|
||
id: 'preflight-fingerprint-from-saved-config',
|
||
// CODE-REVIEW-295-r1 M1: хэш геометрии обязан браться из кандидата,
|
||
// который проверял preflight, а не из сохранённого конфига — иначе блок
|
||
// повторяет то, что и так даст экспорт пространства.
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node demo/smoke_preflight_diagnostics.mjs',
|
||
because: 'диагностика с хэшем сохранённой геометрии не несёт ничего сверх экспорта (AC4)',
|
||
patches: [{
|
||
file: 'src/houseplan-card.ts',
|
||
find: " const spacesById = new Map(((candidate as any)?.spaces || [])",
|
||
replace: " const spacesById = new Map(((this._serverCfg as any)?.spaces || [])",
|
||
}],
|
||
},
|
||
{
|
||
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 '
|
||
+ '&& node demo/smoke_zero_wall_migration_unblocked.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: инлайн-фолбэк живёт одно показание диалога;
|
||
// переживший закрытие блок подсунет в отчёт диагностику чужого отказа.
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node demo/smoke_preflight_diagnostics.mjs',
|
||
because: 'застрявший фолбэк отдаёт в баг-репорт JSON предыдущего отказа, не текущего (AC4)',
|
||
patches: [{
|
||
file: 'src/houseplan-card.ts',
|
||
find: "dismiss-on-scrim @hp-close=${() => { this._alignDialog = null; this._preflightClipboardFallback = null; }}>",
|
||
replace: "dismiss-on-scrim @hp-close=${() => { this._alignDialog = null; }}>",
|
||
}],
|
||
},
|
||
{
|
||
id: 'preflight-diagnostics-without-reason',
|
||
// #295: копируемый блок без reason бесполезен для отчёта об ошибке.
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node demo/smoke_preflight_diagnostics.mjs',
|
||
because: 'диагностический блок обязан нести reason каждого отказа',
|
||
patches: [{
|
||
file: 'src/houseplan-card.ts',
|
||
find: " reason: failure.reason,",
|
||
replace: " reason: undefined,",
|
||
}],
|
||
},
|
||
{
|
||
id: 'preflight-dev-log-disabled',
|
||
// #295: dev-лог — второй канал диагностики, его потерю обязан ловить смок.
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node demo/smoke_preflight_diagnostics.mjs',
|
||
because: 'структурированная запись отказа в консоли — часть контракта диагностики #295',
|
||
patches: [{
|
||
file: 'src/houseplan-card.ts',
|
||
find: " console.warn('[houseplan] optimize preflight failed', this._preflightDiagnostics(preflight, candidate));",
|
||
replace: " void preflight; void candidate;",
|
||
}],
|
||
},
|
||
{
|
||
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;',
|
||
}],
|
||
},
|
||
{
|
||
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 = 507 if err.code == "capacity_exceeded" else 400\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: 'a real CSS :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]:hover { z-index: 8; }',
|
||
replace: ':host([data-pointer-hover]) .dev[data-hp-zigbee-topology-endpoint]: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<HTMLElement>(`[${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`<hp-zigbee-topology-overlay aria-hidden="true" .hass=${input.hass} .devices=${input.devices}',
|
||
replace: ' return html`<hp-zigbee-topology-overlay aria-hidden="true" data-hp-live-layer="camera" .hass=${input.hass} .devices=${input.devices}',
|
||
}],
|
||
},
|
||
{
|
||
id: 'zigbee-topology-unknown-casing-removed',
|
||
guard: 'node demo/smoke_zigbee_topology_hover.mjs',
|
||
because: 'an unknown-LQI dash needs one CSS pixel of dark casing on each side instead of '
|
||
+ 'the old low-contrast gray-only stroke (#464 AC3)',
|
||
patches: [{
|
||
file: 'src/hp-zigbee-topology-overlay.ts',
|
||
find: ' stroke-width="4" stroke-dasharray="5 5" stroke-dashoffset="0" opacity=".9"></line>` : nothing}',
|
||
replace: ' stroke-width="2" stroke-dasharray="5 5" stroke-dashoffset="0" opacity=".9"></line>` : nothing}',
|
||
}],
|
||
},
|
||
{
|
||
id: 'zigbee-topology-unknown-casing-gaps-filled',
|
||
guard: 'node demo/smoke_zigbee_topology_hover.mjs',
|
||
because: 'the wider casing must share the core dash rhythm; a solid casing would silently '
|
||
+ 'turn the unknown-quality link into a continuous route (#464 AC3)',
|
||
patches: [{
|
||
file: 'src/hp-zigbee-topology-overlay.ts',
|
||
find: ' stroke-width="4" stroke-dasharray="5 5" stroke-dashoffset="0" opacity=".9"></line>` : nothing}',
|
||
replace: ' stroke-width="4" stroke-dasharray="none" stroke-dashoffset="0" opacity=".9"></line>` : nothing}',
|
||
}],
|
||
},
|
||
{
|
||
id: 'zigbee-topology-zha-read-starts-scan',
|
||
guard: 'node --test test/zigbee-topology.test.mjs',
|
||
because: 'hover diagnostics may read the existing ZHA snapshot but must never turn a read '
|
||
+ 'into an implicit radio scan (#54 AC4)',
|
||
patches: [{
|
||
file: 'src/zigbee-topology-runtime.ts',
|
||
find: " return normalizeZhaTopology(await hass.callWS({ type: 'zha/devices' }));",
|
||
replace: " return normalizeZhaTopology(await hass.callWS({ type: 'zha/topology/update' }));",
|
||
}],
|
||
},
|
||
{
|
||
id: 'zigbee-topology-ambiguous-marker-selected',
|
||
guard: 'node --test test/zigbee-topology.test.mjs',
|
||
because: 'a Zigbee node with multiple drawable placements must fail closed instead of '
|
||
+ 'drawing a plausible but false neighbour line (#54 AC8)',
|
||
patches: [{
|
||
file: 'src/zigbee-topology.ts',
|
||
find: ' if (candidates.length === 1) placements.set(node.key, {',
|
||
replace: ' if (candidates.length >= 1) placements.set(node.key, {',
|
||
}],
|
||
},
|
||
{
|
||
id: 'topology-help-renders-without-aria',
|
||
guard: 'node --test --test-name-pattern="кружок справки не рисуется|вызывается из блока" '
|
||
+ 'test/zigbee-topology.test.mjs',
|
||
because: 'a help circle that opens without an accessible name is worse than no circle, and '
|
||
+ 'topologyT answers a missing key with the key itself — so absence has to be asked of the '
|
||
+ 'dictionary, not of the resolved string (#459 AC2)',
|
||
patches: [{
|
||
file: 'src/i18n/topology.ts',
|
||
find: ' const value = DICTIONARIES[lang]?.[key] ?? en[key];',
|
||
replace: ' const value = DICTIONARIES[lang]?.[key] ?? en[key] ?? key;',
|
||
}],
|
||
},
|
||
{
|
||
id: 'topology-help-drops-arrow-legend',
|
||
guard: 'node --test --test-name-pattern="все шесть пунктов легенды|не путь пакета" '
|
||
+ 'test/zigbee-topology.test.mjs',
|
||
because: 'a hint that names only colour and dashes passes «the help exists» while answering '
|
||
+ 'none of the questions the arrows raise — the very content this task waited for #457 to '
|
||
+ 'write (#459 AC3, AC3b)',
|
||
patches: [{
|
||
file: 'src/i18n/topology/ru.json',
|
||
find: ' Стрелка ведёт к следующему устройству по пути к координатору: исходящая одна, входящие — те, кто ходит через это устройство. Линия без стрелки — запасной сосед. Это дерево маршрутов, которое строит House Plan, а не путь пакета в эту секунду: подпись на конце стрелки значит, что цель не на этом плане, а отсутствие стрелки — что путь неизвестен.',
|
||
replace: '',
|
||
}],
|
||
},
|
||
{
|
||
id: 'zigbee-topology-z2m-camelcase-node-rejected',
|
||
guard: 'node --test --test-name-pattern="real anonymized camelCase" test/zigbee-topology.test.mjs',
|
||
because: 'the Zigbee2MQTT raw network-map contract uses ieeeAddr; accepting only the '
|
||
+ 'bridge/devices snake_case spelling makes every real scan fail as invalid (#450 AC1)',
|
||
patches: [{
|
||
file: 'src/zigbee-topology.ts',
|
||
find: ' const ieee = normalizeIeee(record?.ieeeAddr ?? record?.ieee_address ?? record?.ieee);',
|
||
replace: ' const ieee = normalizeIeee(record?.ieee_address ?? record?.ieee);',
|
||
}],
|
||
},
|
||
{
|
||
id: 'zigbee-topology-z2m-foreign-response-accepted',
|
||
guard: 'node --test test/zigbee-topology.test.mjs',
|
||
because: 'parallel Zigbee2MQTT requests share one response topic; only the matching '
|
||
+ 'transaction may complete this snapshot (#54 AC6)',
|
||
patches: [{
|
||
file: 'src/zigbee-topology-runtime.ts',
|
||
find: ' if (value && transactionOf(value) === transaction) responseResolve?.(value);',
|
||
replace: ' if (value) responseResolve?.(value);',
|
||
}],
|
||
},
|
||
{
|
||
id: 'zigbee-topology-z2m-malformed-response-waits-for-timeout',
|
||
guard: 'node --test --test-name-pattern="malformed response immediately" test/zigbee-topology.test.mjs',
|
||
because: 'a malformed response on the subscribed network-map topic must report invalid payload '
|
||
+ 'immediately instead of leaving the settings dialog loading for the full timeout (#54 AC16)',
|
||
patches: [{
|
||
file: 'src/zigbee-topology-runtime.ts',
|
||
find: " if (value === null) {\n if (responseActive) responseReject?.(fail('invalid_payload'));\n return;\n }",
|
||
replace: ' if (value === null) return;',
|
||
}],
|
||
},
|
||
{
|
||
id: 'zigbee-topology-z2m-subscriptions-leak',
|
||
guard: 'node --test test/zigbee-topology.test.mjs',
|
||
because: 'both MQTT subscriptions must be released after success or failure so one manual '
|
||
+ 'refresh cannot leave listeners processing later payloads (#54 AC6)',
|
||
patches: [{
|
||
file: 'src/zigbee-topology-runtime.ts',
|
||
find: ' try { unsubscribe(); } catch { /* cleanup is best effort */ }',
|
||
replace: ' try { void unsubscribe; } catch { /* cleanup is best effort */ }',
|
||
}],
|
||
},
|
||
{
|
||
id: 'zigbee-route-relationship-separators-normalized',
|
||
guard: 'node --test --test-name-pattern="Z2M relationship strings" test/zigbee-topology.test.mjs',
|
||
because: 'provider relationship strings must ignore separators before the parent tie-break '
|
||
+ 'uses them (#457 section 6.3 and code-review r1)',
|
||
patches: [{
|
||
file: 'src/zigbee-topology.ts',
|
||
find: " const compact = value.trim().toLowerCase().replace(/[\\s_-]+/g, '');",
|
||
replace: " const compact = value.trim().toLowerCase();",
|
||
}],
|
||
},
|
||
{
|
||
id: 'zigbee-route-parent-keeps-bfs-level',
|
||
guard: 'node --test --test-name-pattern="uplink tree is deterministic" test/zigbee-topology.test.mjs',
|
||
because: 'every parent must be one BFS level nearer the coordinator; accepting a same-level '
|
||
+ 'neighbour can create a cycle and break the defining #457 AC1 invariant',
|
||
patches: [{
|
||
file: 'src/zigbee-topology.ts',
|
||
find: ' .filter(({ neighborKey }) => distances.get(neighborKey) === distance - 1)',
|
||
replace: ' .filter(({ neighborKey }) => distances.get(neighborKey) === distance)',
|
||
}],
|
||
},
|
||
{
|
||
id: 'zigbee-route-local-arrow-not-inverted',
|
||
guard: 'node --test --test-name-pattern="hover projects local route directions" test/zigbee-topology.test.mjs',
|
||
because: 'the arrow on the hovered device uplink must point to its parent rather than away '
|
||
+ 'from the coordinator (#457 AC4)',
|
||
patches: [{
|
||
file: 'src/zigbee-topology.ts',
|
||
find: " const direction = isParent ? 'toward-neighbor'",
|
||
replace: " const direction = isParent ? 'toward-origin'",
|
||
}],
|
||
},
|
||
{
|
||
id: 'zigbee-route-parent-not-counted-twice',
|
||
guard: 'node --test --test-name-pattern="hover projects local route directions" test/zigbee-topology.test.mjs',
|
||
because: 'a remote parent already has a named route bubble and must not also inflate the '
|
||
+ 'legacy cross-space neighbour count (#457 AC6)',
|
||
patches: [{
|
||
file: 'src/zigbee-topology.ts',
|
||
find: ' if (!isParent) remote.add(other.markerId);',
|
||
replace: ' remote.add(other.markerId);',
|
||
}],
|
||
},
|
||
{
|
||
id: 'double-fit-free-background-owner-removed',
|
||
guard: 'node --test --test-name-pattern="#449 only" test/room-fit.test.mjs',
|
||
because: 'room, device and opening owners must never become a half of the free-background '
|
||
+ 'double-fit sequence (#449 AC4–AC5)',
|
||
patches: [{
|
||
file: 'src/room-fit.ts',
|
||
find: " if (!modality || input.mode !== 'view' || input.owner.kind !== 'background'",
|
||
replace: " if (!modality || input.mode !== 'view' || false",
|
||
}],
|
||
},
|
||
{
|
||
id: 'double-fit-navigation-block-ignored',
|
||
guard: 'node --test --test-name-pattern="#449 moved" test/room-fit.test.mjs',
|
||
because: 'a pan, pinch, swipe, long press or cancelled pointer must disarm the sequence '
|
||
+ 'instead of completing fit-all (#449 AC6)',
|
||
patches: [{
|
||
file: 'src/room-fit.ts',
|
||
find: " || input.blocked || candidate.pointerId !== input.pointerId",
|
||
replace: ' || candidate.pointerId !== input.pointerId',
|
||
}],
|
||
},
|
||
{
|
||
id: 'double-fit-editor-mode-enabled',
|
||
guard: 'node --test --test-name-pattern="#449 only" test/room-fit.test.mjs',
|
||
because: 'the shortcut belongs only to View and kiosk; editor double-click contracts must '
|
||
+ 'remain untouched (#449 AC7)',
|
||
patches: [{
|
||
file: 'src/room-fit.ts',
|
||
find: "!modality || input.mode !== 'view' || input.owner.kind !== 'background'",
|
||
replace: "!modality || input.owner.kind !== 'background'",
|
||
}],
|
||
},
|
||
{
|
||
id: 'double-fit-bypasses-canonical-fit-all',
|
||
guard: 'node demo/smoke_room_fit.mjs',
|
||
because: 'the gesture must share the toolbar command, including far-object framing and '
|
||
+ 'room-focus cleanup, rather than resetting only camera zoom (#449 AC1, AC9)',
|
||
patches: [{
|
||
file: 'src/houseplan-card.ts',
|
||
find: " if (doubleFit) this._fitAll('double-tap');",
|
||
replace: " if (doubleFit) this._resetZoom('double-tap');",
|
||
}],
|
||
},
|
||
{
|
||
id: 'adoption-bodies-declared-reactive',
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node --test --test-name-pattern="#520" test/config-adoption-ownership.test.mjs',
|
||
because: 'since #500 `_adoption` owns the reactivity of these bodies; declaring them again '
|
||
+ 'makes Lit a second owner, marks them `wrapped` and force-writes a change on the first '
|
||
+ 'update — today harmless only because both sides are null at that moment, and a '
|
||
+ 'spurious config epoch as soon as a warm cache arrives earlier (#520 AC1)',
|
||
patches: [{
|
||
file: 'src/houseplan-card.ts',
|
||
find: " _devices: { state: true },",
|
||
replace: " _serverCfg: { state: true },\n _devices: { state: true },",
|
||
}],
|
||
},
|
||
{
|
||
id: 'adoption-notifies-no-host-on-config-replacement',
|
||
guard: 'node --test test/config-adoption.test.mjs',
|
||
because: 'since #520 the bodies are no longer declared Lit properties, so this call is the '
|
||
+ 'only thing that wakes an update when the config body is replaced: without it the '
|
||
+ 'geometry epoch never moves and the plan keeps painting the retired body (#520 AC2)',
|
||
patches: [{
|
||
file: 'src/config-adoption.ts',
|
||
find: " this.config = next;\n this.onBodyReplaced?.('_serverCfg', previous);",
|
||
replace: ' this.config = next;',
|
||
}],
|
||
},
|
||
{
|
||
id: 'adoption-tail-defers-caller-hook',
|
||
guard: 'node --test test/config-adoption.test.mjs',
|
||
because: 'the caller hook closes the adoption task; deferring it by even one microtask lets '
|
||
+ 'Lit paint the adopted config first, and the device seeding that follows costs a second '
|
||
+ 'config epoch, a second model build and a second paint of a 60-room house (#520 AC6)',
|
||
patches: [{
|
||
file: 'src/config-adoption.ts',
|
||
find: ' input.afterAdopt?.();',
|
||
replace: ' void Promise.resolve().then(() => input.afterAdopt?.());',
|
||
}],
|
||
},
|
||
{
|
||
id: 'authoritative-load-seeds-devices-after-the-await',
|
||
guard: 'node --test --test-name-pattern="#520" test/config-adoption-ownership.test.mjs',
|
||
because: 'the cold-start regression of #520 itself: seeding the devices outside the adoption '
|
||
+ 'task writes the config back after Lit already painted it, 19 update cycles, 4 model '
|
||
+ 'builds and 4 config epochs against 18/3/3 (#520 AC6)',
|
||
patches: [{
|
||
file: 'src/houseplan-card.ts',
|
||
find: ' this._loadOk = true;\n rebuildDevices();',
|
||
replace: ' this._loadOk = true;',
|
||
}],
|
||
},
|
||
{
|
||
id: 'openings-rendered-without-keys',
|
||
guard: 'node demo/smoke_space_switch_transitions.mjs',
|
||
because: 'Lit reuses list nodes by position: without a key the leaf of the next space '
|
||
+ 'inherits the node of whatever door held that slot, and its 0.6s transform transition '
|
||
+ 'animates a door that never moved (#525 AC1)',
|
||
patches: [{
|
||
file: 'src/houseplan-card.ts',
|
||
find: " return svg`<g class=\"openinglayer\">${keyed(space.id, repeat(items,"
|
||
+ " (o) => o.id, (o) => {",
|
||
replace: " return svg`<g class=\"openinglayer\">${keyed(space.id, items.map((o) => {",
|
||
}],
|
||
},
|
||
{
|
||
id: 'device-markers-rendered-without-keys',
|
||
guard: 'node demo/smoke_space_switch_transitions.mjs',
|
||
because: 'the marker shell carries a `box-shadow` transition, so a positional reuse replays '
|
||
+ 'it on every space switch; the key also keeps `data-id` honest for the live editor '
|
||
+ 'painter, which finds the dragged marker by that attribute (#525 AC2)',
|
||
patches: [{
|
||
file: 'src/houseplan-card.ts',
|
||
find: ' ${keyed(space.id, repeat(devs, (d) => d.id, (d) => this._renderDevice(',
|
||
replace: ' ${keyed(space.id, devs.map((d) => this._renderDevice(',
|
||
}],
|
||
},
|
||
{
|
||
id: 'live-editor-devices-drops-align-guides',
|
||
guard: 'node demo/smoke_align_guides.mjs',
|
||
because: 'the device editor paints nothing else from a template, so dropping the guides '
|
||
+ 'leaves the settled scene as their only owner — and a live gesture never reaches it: '
|
||
+ 'the regression of #521, invisible to a smoke that fabricates the drag',
|
||
patches: [{
|
||
file: 'src/live-editor.ts',
|
||
find: " return svg`<g class=\"hp-live-devices\" aria-hidden=\"true\" pointer-events=\"none\">\n"
|
||
+ ' ${host._renderAlignGuides()}\n'
|
||
+ ' </g>`;',
|
||
replace: ' return nothing;',
|
||
}],
|
||
},
|
||
{
|
||
id: 'live-editor-decor-drops-align-guides',
|
||
guard: 'node demo/smoke_align_guides.mjs',
|
||
because: 'drawing a shape in the backdrop editor is a live gesture too: without the guides '
|
||
+ 'in its own layer the corner snap has nothing to show (#521 AC3)',
|
||
patches: [{
|
||
file: 'src/live-editor.ts',
|
||
find: ' ${host._renderDecorLayer(activeId)}\n ${host._renderAlignGuides()}',
|
||
replace: ' ${host._renderDecorLayer(activeId)}',
|
||
}],
|
||
},
|
||
{
|
||
id: 'live-editor-plan-drops-align-guides',
|
||
guard: 'node demo/smoke_align_guides.mjs',
|
||
because: 'in the plan editor the guides used to reappear only on a click; without them in '
|
||
+ 'the live layer the contour is drawn against a guide frozen at the last click (#521 AC4)',
|
||
patches: [{
|
||
file: 'src/live-editor.ts',
|
||
find: ' ${host._renderAlignGuides()}\n'
|
||
+ " ${host._tool === 'draw' ? nothing : host._renderPlanSnapOverlay()}",
|
||
replace: " ${host._tool === 'draw' ? nothing : host._renderPlanSnapOverlay()}",
|
||
}],
|
||
},
|
||
{
|
||
id: 'live-editor-keeps-the-settled-guides-visible',
|
||
guard: 'node demo/smoke_align_guides.mjs',
|
||
because: 'a settled render landing mid-gesture leaves its own guides behind; without '
|
||
+ 'hiding that copy the next live paint adds a second one, and the leftover is a step '
|
||
+ 'behind the marker — two dashed lines for one alignment (#521 AC5)',
|
||
patches: [{
|
||
file: 'src/live-editor.ts',
|
||
find: " makeTransparent(state, root, '.hp-editor-only-layer:not(.hp-plan-snap-layer)');\n"
|
||
+ " if (host._mode === 'plan') {\n"
|
||
+ ' if (host._opDrag) hide(state, root, \'.wallbodies\');',
|
||
replace: " if (host._mode === 'plan') {\n"
|
||
+ " makeTransparent(state, root, '.hp-editor-only-layer:not(.hp-plan-snap-layer)');\n"
|
||
+ ' if (host._opDrag) hide(state, root, \'.wallbodies\');',
|
||
}],
|
||
},
|
||
{
|
||
id: 'align-point-reads-frozen-snapshot',
|
||
guard: 'node demo/smoke_align_guides.mjs',
|
||
because: 'during a live gesture `_pos` answers from the snapshot of the last settled '
|
||
+ 'render, so the guide is measured from where the marker stood before the drag — eight '
|
||
+ 'grid steps away on a measured run (#521 AC2)',
|
||
patches: [{
|
||
file: 'src/houseplan-card.ts',
|
||
find: ' return d ? (() => { const p = this._livePos(d); return [p.x, p.y]; })() : null;',
|
||
replace: ' return d ? (() => { const p = this._pos(d); return [p.x, p.y]; })() : null;',
|
||
}],
|
||
},
|
||
{
|
||
id: 'live-editor-view-mode-routes-live',
|
||
guard: 'node --test test/live-editor.test.mjs',
|
||
because: 'View is the product for two of three personas: the live editor path must never '
|
||
+ 'take over rendering there, or a plan nobody is editing stops reacting to Home '
|
||
+ 'Assistant (#451, #458)',
|
||
patches: [{
|
||
file: 'src/live-editor.ts',
|
||
find: " if (!host?.isConnected || host._mode === 'view') return false;",
|
||
replace: ' if (!host?.isConnected) return false;',
|
||
}],
|
||
},
|
||
{
|
||
id: 'live-editor-first-gesture-frame-goes-live',
|
||
guard: 'node --test test/live-editor.test.mjs',
|
||
because: 'the first frame of a gesture may change selection and chrome, so it must stay '
|
||
+ 'reactive; routing it live leaves the newly selected object drawn in its previous '
|
||
+ 'state until the gesture ends (#451, #458)',
|
||
patches: [{
|
||
file: 'src/live-editor.ts',
|
||
find: ' if (name !== undefined && gestureProperties.has(name)\n'
|
||
+ ' && oldValue == null\n'
|
||
+ ' && (host as unknown as Record<PropertyKey, unknown>)[name] != null\n'
|
||
+ ' && activeEditorGesture(host)) {\n'
|
||
+ ' // Pointerdown may change selection/chrome once. Subsequent moves stay live.\n'
|
||
+ ' return false;\n'
|
||
+ ' }\n',
|
||
replace: '',
|
||
}],
|
||
},
|
||
{
|
||
id: 'live-editor-settlement-runs-ahead-of-paint',
|
||
guard: 'node --test --test-name-pattern="live editor settlement waits" test/live-editor.test.mjs',
|
||
because: 'the explicit live-editor barrier must resolve only after the scheduled DOM paint; '
|
||
+ 'an eager promise recreates the nondeterministic preview snapshot from #460',
|
||
patches: [{
|
||
file: 'src/live-editor.ts',
|
||
find: ' return new Promise((resolve) => state.waiters.push({ revision, resolve }));',
|
||
replace: ' return Promise.resolve();',
|
||
}],
|
||
},
|
||
{
|
||
id: 'live-editor-smoke-falls-back-to-double-raf',
|
||
guard: 'node --test --test-name-pattern="browser smokes use the explicit" test/live-editor.test.mjs',
|
||
because: 'a fixed number of animation frames is a timing guess, not evidence that the live '
|
||
+ 'editor applied its latest projection (#460)',
|
||
patches: [{
|
||
file: 'demo/smoke_furniture.mjs',
|
||
find: ' const settleLive = () => c._editorRuntime?._whenLiveEditorSettled() ?? c.updateComplete;',
|
||
replace: ' const settleLive = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));',
|
||
}],
|
||
},
|
||
{
|
||
id: 'pointer-move-queue-keeps-first-move',
|
||
guard: 'node --test test/live-editor.test.mjs',
|
||
because: 'the coalescing queue is last-wins: keeping the FIRST callback of an event turn '
|
||
+ 'means the pointer moves on and the plan paints where the finger used to be (#451, #458)',
|
||
patches: [{
|
||
file: 'src/pointer-move-queue.ts',
|
||
find: ' if (queued) {\n queued.run = run;\n return;\n }',
|
||
replace: ' if (queued) {\n return;\n }',
|
||
}],
|
||
},
|
||
{
|
||
id: 'live-hass-tick-never-deferred',
|
||
guard: 'node --test test/houseplan-render-lifecycle.test.mjs',
|
||
because: 'a state tick arriving mid-gesture is deferred on purpose; rendering it '
|
||
+ 'immediately drops frames of the gesture the user is performing, and the deferral flag '
|
||
+ 'is what guarantees the skipped tick is replayed afterwards (#451, #458)',
|
||
patches: [{
|
||
file: 'src/live-interaction-runtime.ts',
|
||
find: " const defer = change === 'state' && this.active();",
|
||
replace: ' const defer = false;',
|
||
}],
|
||
},
|
||
{
|
||
id: 'live-viewport-identity-projection-not-recognized',
|
||
guard: 'node --test test/live-viewport.test.mjs',
|
||
because: 'a settled viewport must be recognized as identity and leave NO compositor '
|
||
+ 'transform: an identity transform still switches the browser compositing path and '
|
||
+ 'shifts the settled raster by a few colour levels, which is exactly what makes golden '
|
||
+ 'frames flap (#451, #458)',
|
||
patches: [{
|
||
file: 'src/live-viewport.ts',
|
||
// Первая редакция этого мутанта снимала `setLayerProjection(layer, null)`
|
||
// из `commitHouseplanViewport` (так предлагал issue) — и тест оставался
|
||
// ЗЕЛЁНЫМ: следом идёт `paintLiveViewport(root, painted, painted)`, а он
|
||
// на равных аргументах даёт identity и обнуляет проекцию сам. То есть тот
|
||
// цикл не контракт, а подстраховка. Настоящий контракт — распознавание
|
||
// identity, и мутируется именно оно.
|
||
find: ' projection.translateXPercent === 0',
|
||
replace: ' projection.translateXPercent === 1',
|
||
}],
|
||
},
|
||
{
|
||
id: 'live-pan-incoming-edge-clipped',
|
||
guard: 'node demo/smoke_live_pan_coverage.mjs',
|
||
because: '#544: a transformed SVG keeps its old viewport. Without temporarily exposing '
|
||
+ 'the scene inside the outer stage clip, the incoming edge becomes stage background '
|
||
+ 'until pointerup or a budgeted viewBox refresh; terminal screenshots miss the defect',
|
||
patches: [{
|
||
file: 'src/live-viewport.ts',
|
||
find: " if (options.exposeSceneOverflow && style.overflow !== 'visible') {\n"
|
||
+ " style.overflow = 'visible';\n"
|
||
+ ' }',
|
||
replace: '',
|
||
}],
|
||
},
|
||
{
|
||
id: 'live-pan-rewrites-viewbox-every-frame',
|
||
guard: 'node demo/smoke_live_pan_viewbox.mjs',
|
||
because: '#531: перезапись `view' + 'Box` — это инвалидация растеризации всей сцены, её '
|
||
+ 'нельзя сдвинуть, её надо нарисовать заново; в профиле владельца кадр панорамы '
|
||
+ 'доезжал до экрана 200 мс, а драйвер пропускал 124-144 тика в секунду с пометкой '
|
||
+ '«ждём краску». Кадр жеста обязан быть трансформом, а не новой краской',
|
||
patches: [{
|
||
file: 'src/live-viewport.ts',
|
||
find: ' || !finiteView(base.frame.floor) || needsViewBoxRefresh(base, current, now);',
|
||
replace: ' || !finiteView(base.frame.floor) || true;',
|
||
}],
|
||
},
|
||
{
|
||
id: 'live-pan-writes-unchanged-viewbox',
|
||
guard: 'node --test test/live-viewport.test.mjs',
|
||
because: '#531: тихий кадр обязан оставлять DOM нетронутым. Запись того же значения в '
|
||
+ 'атрибут — это всё равно инвалидация стиля, и в обычном режиме узлы floor и camera '
|
||
+ 'получают ровно одну и ту же строку кадр за кадром',
|
||
patches: [{
|
||
file: 'src/live-viewport.ts',
|
||
find: " if (svg.getAttribute('view" + "Box') !== text) svg.setAttribute('view" + "Box', text);",
|
||
replace: " svg.setAttribute('view" + "Box', text);",
|
||
}],
|
||
},
|
||
{
|
||
id: 'live-pan-shift-threshold-ignored',
|
||
guard: 'node --test test/live-viewport.test.mjs',
|
||
because: '#531: временного бюджета мало — за 100 мс рывка план уезжает на пол-экрана, и '
|
||
+ 'на набегающем крае остаётся пустая полоса. Сдвиговый порог существует ровно ради '
|
||
+ 'этого случая и обязан срабатывать раньше времени',
|
||
patches: [{
|
||
file: 'src/live-viewport.ts',
|
||
find: ' if (Math.abs(after.x - before.x) >= after.w * LIVE_VIEWBOX_REFRESH_SHIFT)'
|
||
+ ' return true;\n'
|
||
+ ' if (Math.abs(after.y - before.y) >= after.h * LIVE_VIEWBOX_REFRESH_SHIFT)'
|
||
+ ' return true;\n',
|
||
replace: '',
|
||
}],
|
||
},
|
||
{
|
||
id: 'gate-blames-the-author-for-its-own-stale-dispatch',
|
||
guard: 'node --test test/validate-gate.test.mjs',
|
||
because: '#539: `workflow_dispatch` принимает только ref, и конвейер сам переписывает '
|
||
+ 'ветку ребейзом за секунды до запуска. Без повторной попытки собственная гонка '
|
||
+ 'выглядит как «материал сменился», и задача уезжает автору, которому чинить нечего — '
|
||
+ 'ровно это случилось с #536 12.09',
|
||
patches: [{
|
||
file: 'scripts/validate-gate.mjs',
|
||
find: ' if (elsewhere && attempts < DISPATCH_ATTEMPTS) {',
|
||
replace: ' if (false && elsewhere && attempts < DISPATCH_ATTEMPTS) {',
|
||
}],
|
||
},
|
||
{
|
||
id: 'rebase-dispatches-before-the-ref-settles',
|
||
guard: 'node --test test/review-doc-guard.test.mjs',
|
||
because: '#539: ссылка на стороне GitHub доезжает не мгновенно, а диспатч идёт по имени '
|
||
+ 'ветки. Без ожидания шаг возвращается сразу после push, и запуск встаёт на вершину, '
|
||
+ 'которой на ветке уже нет',
|
||
patches: [{
|
||
file: '.github/workflows/process.yml',
|
||
find: ' if [ "$seen" = "$after" ]; then settled=true; break; fi',
|
||
replace: ' settled=true; break',
|
||
}],
|
||
},
|
||
{
|
||
id: 'announce-listens-to-the-release-event-again',
|
||
guard: 'node --test test/release-workflow.test.mjs',
|
||
because: '#538: пока анонс висел на самом событии `release: published`, он бежал '
|
||
+ 'параллельно гейту и обгонял его — проверять ему нечего. 12.09 v1.75.0 объявили в '
|
||
+ 'канале в ту же минуту, когда проверка отказала выкладывать ассеты, и снаружи это '
|
||
+ 'выглядело обычным релизом',
|
||
patches: [{
|
||
file: '.github/workflows/announce.yml',
|
||
find: 'on:\n workflow_dispatch: {}',
|
||
replace: 'on:\n release:\n types: [published]\n workflow_dispatch: {}',
|
||
}],
|
||
},
|
||
{
|
||
id: 'announce-job-stops-waiting-for-the-assets',
|
||
guard: 'node --test test/release-workflow.test.mjs',
|
||
because: '#538: зависимость анонса от выкладки — это и есть правило «молчание тоже '
|
||
+ 'ответ». Без неё анонс уходит при красном гейте, то есть ровно то, что случилось',
|
||
patches: [{
|
||
file: '.github/workflows/release.yml',
|
||
find: ' name: Оповещение о релизе после выкладки\n needs: build',
|
||
replace: ' name: Оповещение о релизе после выкладки\n if: always()',
|
||
}],
|
||
},
|
||
{
|
||
id: 'render-invalidation-unknown-key-ignored',
|
||
guard: 'node --test test/render-invalidation.test.mjs',
|
||
because: 'the classifier fails OPEN on Home Assistant keys it does not know: without that '
|
||
+ 'rule a new HA capability silently leaves the plan stale, and a stale plan looks '
|
||
+ 'exactly like a working one (#451, #458)',
|
||
patches: [{
|
||
file: 'src/render-invalidation.ts',
|
||
find: ' for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {\n'
|
||
+ " if (!compared.has(key) && before[key] !== after[key]) return 'structural';\n"
|
||
+ ' }\n',
|
||
replace: '',
|
||
}],
|
||
},
|
||
{
|
||
id: 'resize-live-preflight-keeps-every-room',
|
||
guard: 'node --test test/resize-controller.test.mjs',
|
||
because: 'live resize checks only the rooms the gesture touches; widening that to the whole '
|
||
+ 'plan turns every frame into a full-plan geometry check and the drag stutters on large '
|
||
+ 'plans — the very regression #451 removed (#458)',
|
||
patches: [{
|
||
file: 'src/resize-live-preflight.ts',
|
||
find: ' return id && changed.has(id) ? [id] : [];',
|
||
replace: ' return id ? [id] : [];',
|
||
}],
|
||
},
|
||
{
|
||
id: 'render-lifecycle-diagnostics-cache-never-invalidated',
|
||
guard: 'node --test test/houseplan-render-lifecycle.test.mjs',
|
||
because: 'the diagnostics cache must drop when the registry or a tracked state appears or '
|
||
+ 'disappears; a cache that never invalidates reports yesterday binding health as todays '
|
||
+ 'and the red dot for a silently added device never lights (#451, #458)',
|
||
patches: [{
|
||
file: 'src/houseplan-render-lifecycle.ts',
|
||
find: ' if (!this.diagnosticsCache) return;\n',
|
||
replace: ' if (!this.diagnosticsCache) return;\n if (true) return;\n',
|
||
}],
|
||
},
|
||
{
|
||
id: 'iso-placement-cache-ignores-selected',
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node --test --test-name-pattern="#473 W1" test/iso-scene-render.test.mjs',
|
||
because: 'a selected plate must resolve its own placement; a signature without `selected` '
|
||
+ 'hands back the unselected cached one — a stale picture with no failure (#473, perf delta of #160)',
|
||
patches: [{
|
||
file: 'src/iso-scene-render.ts',
|
||
find: " input.layers.shadows ? 1 : 0, selected ? 1 : 0].join('|');",
|
||
replace: " input.layers.shadows ? 1 : 0].join('|');",
|
||
}],
|
||
},
|
||
{
|
||
id: 'iso-placement-cache-survives-silhouette-change',
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node --test --test-name-pattern="#473 W2" test/iso-scene-render.test.mjs',
|
||
because: 'placements are cached per wall-silhouette array identity; keying the cache by a '
|
||
+ 'constant serves «near the wall» for a plan whose wall is gone (#473)',
|
||
patches: [{
|
||
file: 'src/iso-scene-render.ts',
|
||
find: ' let placements = isoOverlayPlacementCache.get(input.wallSilhouettes);',
|
||
replace: ' let placements = isoOverlayPlacementCache.get(ISO_PLACEMENT_CACHE_ANY);',
|
||
}, {
|
||
file: 'src/iso-scene-render.ts',
|
||
find: ' isoOverlayPlacementCache.set(input.wallSilhouettes, placements);',
|
||
replace: ' isoOverlayPlacementCache.set(ISO_PLACEMENT_CACHE_ANY, placements);',
|
||
}, {
|
||
file: 'src/iso-scene-render.ts',
|
||
find: 'export const ISO_OVERLAY_PLACEMENT_CACHE_LIMIT = 2048;',
|
||
replace: 'export const ISO_OVERLAY_PLACEMENT_CACHE_LIMIT = 2048;\nconst ISO_PLACEMENT_CACHE_ANY: readonly IsoWallSilhouette[] = [];',
|
||
}],
|
||
},
|
||
{
|
||
id: 'iso-zoom-in-reuses-near-wall-plate',
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node --test --test-name-pattern="#473 W3" test/iso-scene-render.test.mjs',
|
||
because: 'zoom-in may reuse a placement only if the plate was never near a wall or was '
|
||
+ 'cleared within the cap; dropping the first guard reuses a pinned plate at a scale where it '
|
||
+ 'already cuts the wall (#473)',
|
||
patches: [{
|
||
file: 'src/iso-scene-render.ts',
|
||
find: ' if (!previous.nearWallBefore',
|
||
replace: ' if (true',
|
||
}],
|
||
},
|
||
// #474: designer furniture artwork is a lazy chunk. Each protective contract
|
||
// of the runtime and its integration gets one witness.
|
||
{
|
||
id: 'furniture-art-eager-import',
|
||
guard: 'npm run build && node scripts/bundle-budget.mjs',
|
||
because: 'a static import of the artwork anywhere in the View graph silently pulls ~10 KB gzip '
|
||
+ 'back into the initial graph — the exact debt #474 removes; the budget must see the chunk as lazy',
|
||
patches: [{
|
||
file: 'src/furniture.ts',
|
||
find: "import { FURNITURE_ART_RUNTIME, type FurnitureArtHost } from './furniture-art-runtime';",
|
||
replace: "import { FURNITURE_ART_RUNTIME, type FurnitureArtHost } from './furniture-art-runtime';\n"
|
||
+ "import { GENERATED_FURNITURE_ART as EAGER_ART } from './furniture-plan-art.generated'; // mutant",
|
||
}, {
|
||
// A well-meaning "fall back to the bundled artwork" is exactly how the
|
||
// chunk would creep back into the initial graph.
|
||
file: 'src/furniture.ts',
|
||
find: ' if (symbol.designer) return FURNITURE_ART_RUNTIME.art(id, host) ?? null;',
|
||
replace: ' if (symbol.designer) return FURNITURE_ART_RUNTIME.art(id, host) ?? EAGER_ART[id] ?? null;',
|
||
}],
|
||
},
|
||
{
|
||
id: 'furniture-art-fallback-never-settles',
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node --test --test-name-pattern="settle into fallback" test/furniture-art-runtime.test.mjs',
|
||
because: 'fallback must be a SETTLED state: a runtime that stays pending after two failed attempts '
|
||
+ 'keeps the boot gate waiting and re-imports forever (#352–#355 class)',
|
||
patches: [{
|
||
file: 'src/furniture-art-runtime.ts',
|
||
find: ' if (this._art) return;\n this._failed = true;',
|
||
replace: ' if (this._art) return;\n this._failed = false; // mutant: never settle',
|
||
}],
|
||
},
|
||
{
|
||
id: 'furniture-art-no-retry-nonce',
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node --test --test-name-pattern="first attempt failing and the second succeeding" test/furniture-art-runtime.test.mjs',
|
||
because: 'Chromium caches a failed module forever; the second attempt must be a different URL '
|
||
+ 'and must exist at all — a single attempt turns every transient failure into fallback',
|
||
patches: [{
|
||
file: 'src/furniture-art-runtime.ts',
|
||
find: ' for (const attempt of [0, 1] as const) {\n try {\n const module = await this.options.load(attempt);',
|
||
replace: ' for (const attempt of [0] as const) {\n try {\n const module = await this.options.load(attempt);',
|
||
}],
|
||
},
|
||
{
|
||
id: 'furniture-art-boot-gate-ignored',
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node --test --test-name-pattern="boot gate waits only while pending" test/furniture-art-runtime.test.mjs',
|
||
because: 'the boot veil must hold while a plan with furniture waits for its artwork; a gate that '
|
||
+ 'never reports pending lets the first revealed frame miss every piece (#474 AC2)',
|
||
patches: [{
|
||
file: 'src/furniture-art-runtime.ts',
|
||
find: " return runtime.state() === 'pending' && configNeedsFurnitureArt(config, isDesigner);",
|
||
replace: ' return false; // mutant: veil never waits for artwork',
|
||
}],
|
||
},
|
||
{
|
||
id: 'furniture-placement-needs-art',
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node --test --test-name-pattern="wall magnet places a designer piece" test/furniture-art-runtime.test.mjs',
|
||
because: 'the magnet needs the catalogue, not the artwork: checking the artwork refuses every '
|
||
+ 'designer placement while the chunk is pending (#474 AC6)',
|
||
patches: [{
|
||
file: 'src/furniture-placement.ts',
|
||
find: ' if (!furnitureSymbol(symbol) || !(canvasW > 0) || !(canvasH > 0)',
|
||
replace: " if (!(furnitureSymbol(symbol) && (furnitureSymbol(symbol)?.g || FURNITURE_ART_RUNTIME.art(symbol))) || !(canvasW > 0) || !(canvasH > 0)",
|
||
}, {
|
||
file: 'src/furniture-placement.ts',
|
||
find: "import { clampFurnSize, cmToNorm, furnitureSymbol } from './furniture';",
|
||
replace: "import { clampFurnSize, cmToNorm, furnitureSymbol } from './furniture';\nimport { FURNITURE_ART_RUNTIME } from './furniture-art-runtime';",
|
||
}],
|
||
},
|
||
{
|
||
id: 'furniture-art-fingerprint-unchecked',
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node --test --test-name-pattern="foreign build fingerprint is terminal|adopt is synchronous, rejects" test/furniture-art-runtime.test.mjs',
|
||
because: 'artwork from another build must be rejected on both paths (load and adopt): a stale '
|
||
+ 'chunk half-applied to a newer card is the #353 failure mode',
|
||
patches: [{
|
||
file: 'src/furniture-art-runtime.ts',
|
||
find: ' if (module.FURNITURE_ART_FINGERPRINT !== this.options.expectedFingerprint) {',
|
||
replace: ' if (false) { // mutant: any build will do',
|
||
}, {
|
||
file: 'src/furniture-art-runtime.ts',
|
||
find: ' if (fingerprint !== this.options.expectedFingerprint) {',
|
||
replace: ' if (false) { // mutant: any build will do',
|
||
}],
|
||
},
|
||
{
|
||
id: 'furniture-art-editor-adopt-skipped',
|
||
guard: 'node demo/smoke_furniture.mjs',
|
||
because: 'the editor imports the artwork statically and must hand it over synchronously; without '
|
||
+ 'adopt the palette previews and the placement ghost render empty on a plan without furniture (#474 r1)',
|
||
patches: [{
|
||
file: 'src/houseplan-editor-runtime.ts',
|
||
find: 'FURNITURE_ART_RUNTIME.adopt(GENERATED_FURNITURE_ART, FURNITURE_ART_FINGERPRINT);',
|
||
replace: 'void [FURNITURE_ART_RUNTIME, GENERATED_FURNITURE_ART, FURNITURE_ART_FINGERPRINT]; // mutant: no handover',
|
||
}],
|
||
},
|
||
{
|
||
id: 'iso-aabb-rejects-touching-wall',
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node --test --test-name-pattern="#473 W4" test/iso-scene-render.test.mjs',
|
||
because: 'the AABB pre-check must keep every silhouette within the safety gap; a strict '
|
||
+ 'overlap test drops walls the exact test would have caught and lets plates sit flush (#473)',
|
||
patches: [{
|
||
file: 'src/iso-overlays.ts',
|
||
find: ' return a[0] <= b[2] + gap && a[2] >= b[0] - gap\n'
|
||
+ ' && a[1] <= b[3] + gap && a[3] >= b[1] - gap;',
|
||
replace: ' return a[0] <= b[2] && a[2] >= b[0]\n'
|
||
+ ' && a[1] <= b[3] && a[3] >= b[1];',
|
||
}],
|
||
},
|
||
{
|
||
id: 'stage3-w1-camera-rotation-reset',
|
||
guard: 'node --test --test-name-pattern="exact fixed" test/iso-projection.test.mjs',
|
||
because: 'W1: the reviewed Stage 3 camera is exactly +4 degrees; a front-on camera must '
|
||
+ 'fail the exact projection contract (#160)',
|
||
patches: [{
|
||
file: 'src/iso-projection.ts',
|
||
find: ' rotDeg: 4,\n',
|
||
replace: ' rotDeg: 0,\n',
|
||
}],
|
||
},
|
||
{
|
||
id: 'stage3-w2-room-label-left-on-floor',
|
||
guard: 'node --test --test-name-pattern="exact D2 overlay matrix" test/iso-overlays.test.mjs',
|
||
because: 'W2: room labels/cards and value-bearing device roots belong to the raised plane; '
|
||
+ 'the D2 matrix must reject a floor-plane room label (#160)',
|
||
patches: [{
|
||
file: 'src/iso-overlays.ts',
|
||
find: " return showBorders && (kind === 'device' || kind === 'room-label' || kind === 'opening-lock')\n"
|
||
+ " ? 'raised' : 'floor';",
|
||
replace: " return showBorders && (kind === 'device' || kind === 'opening-lock')\n"
|
||
+ " ? 'raised' : 'floor';",
|
||
}],
|
||
},
|
||
{
|
||
id: 'stage3-w3-vacuum-raised-with-devices',
|
||
guard: 'node --test --test-name-pattern="raises only device" test/isometric-contract.test.mjs',
|
||
because: 'W3: the live vacuum puck/trail stays on the floor even while device, room and lock '
|
||
+ 'roots are raised (#160)',
|
||
patches: [{
|
||
file: 'src/houseplan-card.ts',
|
||
find: ' const point = this._scenePoint([cx, cy]);',
|
||
replace: " const point = this._renderProjection === 'iso'\n"
|
||
+ ' ? projectPlanPoint([cx, cy], gridVisualUnits(68, this._cellCm))\n'
|
||
+ ' : [cx, cy] as ScenePoint;',
|
||
}],
|
||
},
|
||
{
|
||
id: 'stage3-w4-device-target-loses-44px-floor',
|
||
guard: 'node demo/smoke_isometric_contract.mjs',
|
||
because: 'W4: the raised interactive root must own an actual 44 by 44 CSS-pixel hit target '
|
||
+ 'on both fine and coarse pointers (#160)',
|
||
patches: [{
|
||
file: 'src/styles/devices.styles.ts',
|
||
find: ' width: max(44px, var(--device-shell-size));\n'
|
||
+ ' height: max(44px, var(--device-shell-size));',
|
||
replace: ' width: var(--device-shell-size);\n'
|
||
+ ' height: var(--device-shell-size);',
|
||
}],
|
||
},
|
||
{
|
||
id: 'stage3-w5-runtime-nudge-writes-storage',
|
||
guard: 'node demo/smoke_isometric_live_touch.mjs',
|
||
because: 'W5: runtime nudge is presentation-only and must never write config, layout or '
|
||
+ 'browser storage (#160)',
|
||
patches: [{
|
||
file: 'src/houseplan-card.ts',
|
||
find: ' return runtime.buildIsoOverlayRenderScene({',
|
||
replace: " localStorage.setItem('houseplan_stage3_nudge_mutant', space.id);\n"
|
||
+ ' return runtime.buildIsoOverlayRenderScene({',
|
||
}],
|
||
},
|
||
{
|
||
id: 'stage3-w6-no-borders-keeps-raised-plates',
|
||
guard: 'node demo/smoke_isometric_live_touch.mjs',
|
||
because: 'W6: show_borders:false is a true affine floor with no volume, raised plate, '
|
||
+ 'ground or tether layers (#160)',
|
||
patches: [{
|
||
file: 'src/houseplan-card.ts',
|
||
find: ' if (!runtime || !layers?.structural || !structural) return null;',
|
||
replace: ' if (!runtime || !layers || (!structural && disp.showBorders)) return null;',
|
||
}, {
|
||
file: 'src/houseplan-card.ts',
|
||
find: ' wallSilhouettes: structural.wallSilhouettes,',
|
||
replace: ' wallSilhouettes: structural?.wallSilhouettes ?? [],',
|
||
}],
|
||
},
|
||
{
|
||
id: 'stage3-w7-sun-state-enters-structural-key',
|
||
guard: 'node demo/smoke_isometric_live_touch.mjs',
|
||
because: 'W7: HA sun state is live paint and must not enter the structural fingerprint or '
|
||
+ 'cause topology rebuilds (#160)',
|
||
patches: [{
|
||
file: 'src/iso-scene-render.ts',
|
||
find: ' onBuild(): void;\n}',
|
||
replace: ' onBuild(): void;\n liveFingerprint?: string;\n}',
|
||
}, {
|
||
file: 'src/iso-scene-render.ts',
|
||
find: ' algorithm: 4,\n })}`;',
|
||
replace: ' algorithm: 4,\n })}|${input.liveFingerprint ?? \'\'}`;',
|
||
}, {
|
||
file: 'src/houseplan-card.ts',
|
||
find: ' onBuild: () => { this._isoStructuralBuildCount += 1; },',
|
||
replace: " liveFingerprint: JSON.stringify(this.hass?.states?.['sun.sun'] ?? null),\n"
|
||
+ ' onBuild: () => { this._isoStructuralBuildCount += 1; },',
|
||
}],
|
||
},
|
||
{
|
||
id: 'stage3-w8-material-defs-created-per-face',
|
||
guard: 'node demo/smoke_isometric_contract.mjs',
|
||
because: 'W8: Stage 3 material patterns and filters are shared O(1) definitions, never '
|
||
+ 'replicated for every face or marker (#160)',
|
||
patches: [{
|
||
file: 'src/iso-scene-render.ts',
|
||
find: ' return svg`<path class="iso-wall-top" d=${face.d} data-component=${face.component}\n'
|
||
+ ' fill-rule="evenodd"></path>${layers.materialNuance',
|
||
replace: ' return svg`${renderIsoDefs(layers, \'walls\', cellCm)}<path class="iso-wall-top" d=${face.d} data-component=${face.component}\n'
|
||
+ ' fill-rule="evenodd"></path>${layers.materialNuance',
|
||
}],
|
||
},
|
||
{
|
||
id: 'stage3-w9-gate-face-flip-reversed',
|
||
guard: 'node --test --test-name-pattern="gate flips move unhosted structural face" '
|
||
+ 'test/iso-scene-render.test.mjs',
|
||
because: 'W9: door/gate hinge and face basis must retain the reviewed flip convention '
|
||
+ 'independently of live leaf state (#160)',
|
||
patches: [{
|
||
file: 'src/iso-scene-render.ts',
|
||
find: " const faceFlipV = opening.type === 'gate' ? !opening.flipV : opening.flipV;",
|
||
replace: " const faceFlipV = opening.flipV;",
|
||
}],
|
||
},
|
||
{
|
||
id: 'stage3-w10-flat-fallback-counted-as-success',
|
||
guard: 'node --test --test-name-pattern="runner fails closed" test/performance-workflow.test.mjs',
|
||
because: 'W10: a dense benchmark sample in Flat fallback is invalid evidence and must fail '
|
||
+ 'before timings are accepted (#160)',
|
||
patches: [{
|
||
file: 'demo/benchmark_large_house.mjs',
|
||
find: " if (snapshot.effectiveProjection !== 'iso') failures.push('effective projection is not iso');",
|
||
replace: " if (false && snapshot.effectiveProjection !== 'iso') failures.push('effective projection is not iso');",
|
||
}],
|
||
},
|
||
{
|
||
id: 'stage3-w11-nudged-overlay-loses-tether',
|
||
guard: 'node --test --test-name-pattern="wall-aware nudge" test/iso-overlays.test.mjs',
|
||
because: 'W11: every non-zero nudge keeps a tether back to its immutable floor owner '
|
||
+ 'anchor (#160)',
|
||
patches: [{
|
||
file: 'src/iso-overlays.ts',
|
||
find: ' const tetherVisible = nudged || nearWallBefore || nearWallAfter\n'
|
||
+ ' || !!input.hovered || !!input.focused || !!input.selected;',
|
||
replace: ' const tetherVisible = !nudged && (nearWallBefore || nearWallAfter\n'
|
||
+ ' || !!input.hovered || !!input.focused || !!input.selected);',
|
||
}],
|
||
},
|
||
{
|
||
id: 'stage3-w12-separate-alpha-url-key-restored',
|
||
guard: 'node --test --test-name-pattern="Labs iso is presentation-only" '
|
||
+ 'test/isometric-contract.test.mjs',
|
||
because: 'W12: Stage 3 shares the one permanent hp_alpha switch; a feature-specific URL '
|
||
+ 'key or expiry must not return (#160)',
|
||
patches: [{
|
||
file: 'src/labs.ts',
|
||
find: "params.getAll('hp_alpha')",
|
||
replace: "params.getAll('hp_alpha_stage3')",
|
||
}],
|
||
},
|
||
{
|
||
id: 'color-picker-invalid-confirm-latch-removed',
|
||
guard: 'node demo/smoke_color_picker.mjs',
|
||
because: 'after an invalid HEX draft is normalized for display, repeated confirmation must '
|
||
+ 'still wait for a new valid input event; only the real picker lifecycle proves that the '
|
||
+ 'normalized fallback cannot bypass the #476 validation latch',
|
||
patches: [{
|
||
file: 'src/hp-color-opacity.ts',
|
||
find: ' if (this._hexNeedsValidInput) {\n'
|
||
+ ' this._hexInvalid = true;\n'
|
||
+ ' return;\n'
|
||
+ ' }',
|
||
replace: ' if (false) {\n'
|
||
+ ' this._hexInvalid = true;\n'
|
||
+ ' return;\n'
|
||
+ ' }',
|
||
}],
|
||
},
|
||
{
|
||
id: 'color-picker-confirm-click-through',
|
||
guard: 'node demo/smoke_color_picker_consumers.mjs',
|
||
because: 'the full-width confirmation inside a general-settings dialog must consume its own '
|
||
+ 'click before it reaches the picker surface; observing that direct ancestor keeps outer '
|
||
+ 'toolbar and modal stoppers from hiding removal of the #476 picker-level defense',
|
||
patches: [{
|
||
file: 'src/hp-color-opacity.ts',
|
||
find: ' private _confirm(event: Event): void {\n'
|
||
+ ' event.preventDefault();\n'
|
||
+ ' event.stopPropagation();',
|
||
replace: ' private _confirm(event: Event): void {\n'
|
||
+ ' event.preventDefault();',
|
||
}],
|
||
},
|
||
// #477: current writers must leave Optimize at a fixed point. These
|
||
// mutants cover every newly owned boundary rather than merely exercising
|
||
// the happy-path pure helper.
|
||
{
|
||
id: 'writer-finish-reset-bypasses-finalizer',
|
||
guard: 'node --test --test-name-pattern="source contract routes" test/writer-fixed-point.test.mjs',
|
||
because: 'Reset is a real wall-chain finish owner; clearing its session directly would leave '
|
||
+ 'the just-authored collinear seams for Optimize to find (#477)',
|
||
patches: [{
|
||
file: 'src/houseplan-editor-runtime.ts',
|
||
find: 'if (this._finishWallChain()) this.host.requestUpdate();',
|
||
replace: 'this._cancelPath(); this.host.requestUpdate(); // mutant: Reset skips finish',
|
||
}],
|
||
},
|
||
{
|
||
id: 'writer-finish-adopts-unsafe-candidate',
|
||
guard: 'node --test --test-name-pattern="source contract routes" test/writer-fixed-point.test.mjs',
|
||
because: 'the completed-chain candidate must pass the bounded physical proof before it can '
|
||
+ 'replace live config; moving adoption across that boundary defeats atomic fail-closed save (#477)',
|
||
patches: [{
|
||
file: 'src/draft-live-commit.ts',
|
||
find: ' const localPrevious = {\n'
|
||
+ ' ...previousConfig, spaces: [previousProjection.space],\n'
|
||
+ ' } as unknown as ServerConfig;\n\n'
|
||
+ ' let geometry: Geometry | null = null;\n'
|
||
+ ' let safe = false;\n'
|
||
+ ' try {\n'
|
||
+ ' const authoredPoints = host._path.length >= 2\n'
|
||
+ ' ? host._path.map((point) => [point[0] / NORM_W, point[1] / NORM_W]) : [];\n'
|
||
+ ' safe = wallModelOffGridValueCount(committedSpace)\n'
|
||
+ ' <= wallModelOffGridValueCount(before, authoredPoints)\n'
|
||
+ ' && host._checkSpacePhysicalGeometry(\n'
|
||
+ ' localCandidate, before.spaceId, (value) => { geometry = value; },\n'
|
||
+ ' ).ok;\n'
|
||
+ ' } catch {\n'
|
||
+ ' safe = false;\n'
|
||
+ ' }\n'
|
||
+ ' if (!safe) return rejectUnsafe(runtime, before);',
|
||
replace: ' const localPrevious = {\n'
|
||
+ ' ...previousConfig, spaces: [previousProjection.space],\n'
|
||
+ ' } as unknown as ServerConfig;\n\n'
|
||
+ ' let geometry: Geometry | null = null;\n'
|
||
+ ' let safe = true; // mutant: physical proof bypassed\n'
|
||
+ ' if (!safe) return rejectUnsafe(runtime, before);',
|
||
}],
|
||
},
|
||
{
|
||
id: 'writer-history-skips-finished-chain-normalization',
|
||
guard: 'node demo/smoke_writer_fixed_point.mjs',
|
||
because: 'Undo/Redo after a chain has finished must restore canonical snapshots rather than '
|
||
+ 'bringing the hidden collinear seams back into durable config (#477)',
|
||
patches: [{
|
||
file: 'src/houseplan-editor-runtime.ts',
|
||
find: ' const target = this._canonicalWallChainHistoryState(state);',
|
||
replace: ' const target = state; // mutant: finished-chain history is restored literally',
|
||
}],
|
||
},
|
||
{
|
||
id: 'writer-room-delete-keeps-direct-reference',
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node --test --test-name-pattern="room reference transaction" test/writer-fixed-point.test.mjs',
|
||
because: 'Delete room owns exact direct marker.room_id cleanup in the same undoable transaction (#477)',
|
||
patches: [{
|
||
file: 'src/room-reference-transaction.ts',
|
||
find: ' if (marker.room_id === operation.roomId) {\n'
|
||
+ ' delete marker.room_id;\n'
|
||
+ ' changed = true;\n'
|
||
+ ' }',
|
||
replace: ' if (marker.room_id === operation.roomId) {\n'
|
||
+ ' changed = true; // mutant: stale direct room reference retained\n'
|
||
+ ' }',
|
||
}],
|
||
},
|
||
{
|
||
id: 'writer-room-delete-keeps-vacuum-reference',
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node --test --test-name-pattern="room reference transaction" test/writer-fixed-point.test.mjs',
|
||
because: 'vacuum segment maps may point across spaces and must lose exactly the deleted room '
|
||
+ 'entry while retaining every unrelated map and wrapper field (#477)',
|
||
patches: [{
|
||
file: 'src/room-reference-transaction.ts',
|
||
find: ' if (operation.kind === \'delete\' && segmentMap[key] === operation.roomId) {\n'
|
||
+ ' delete segmentMap[key];\n'
|
||
+ ' changed = true;\n'
|
||
+ ' }',
|
||
replace: ' if (operation.kind === \'delete\' && segmentMap[key] === operation.roomId) {\n'
|
||
+ ' changed = true; // mutant: stale vacuum room reference retained\n'
|
||
+ ' }',
|
||
}],
|
||
},
|
||
{
|
||
id: 'writer-align-snaps-free-decor-transform',
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node --test --test-name-pattern="continuous furniture/image transforms" test/align-grid.test.mjs',
|
||
because: 'furniture and image transforms are intentionally continuous; counting them as '
|
||
+ 'legacy grid debt makes Optimize alter a normal resize or rotation (#383/#477)',
|
||
patches: [{
|
||
file: 'src/align-grid.ts',
|
||
find: " if (sh.kind === 'furniture' || sh.kind === 'image') continue;",
|
||
replace: " if (false && (sh.kind === 'furniture' || sh.kind === 'image')) continue;",
|
||
}],
|
||
},
|
||
{
|
||
id: 'writer-terminal-click-runs-finish-reconciliation',
|
||
guard: 'node --test --test-name-pattern="source contract routes" test/writer-fixed-point.test.mjs',
|
||
because: 'the latency-critical terminal click persists one crash-safe segment only; global '
|
||
+ 'merge/reconciliation belongs to explicit chain finish and must not return to every click (#461/#477)',
|
||
patches: [{
|
||
file: 'src/houseplan-editor-runtime.ts',
|
||
find: 'public _markupClick(ev: MouseEvent): void {',
|
||
replace: 'public _markupClick(ev: MouseEvent): void {\n'
|
||
+ ' void this._finalizeWallChainPartitions(this.host._activeWallChainPartitionIds); // mutant',
|
||
}],
|
||
},
|
||
{
|
||
id: 'writer-finish-skips-seed-merge',
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node --test --test-name-pattern="collinear chain" test/writer-fixed-point.test.mjs',
|
||
because: 'a finished current-writer chain must collapse its own compatible collinear seams '
|
||
+ 'to fixed point without touching unrelated partitions (#477)',
|
||
patches: [{
|
||
file: 'src/writer-fixed-point.ts',
|
||
find: ' seedIds: seeds,',
|
||
replace: " seedIds: ['writer-seed-never-present'], // mutant: chain never enters merge scope",
|
||
}],
|
||
},
|
||
{
|
||
id: 'writer-finish-skips-coincident-reconciliation',
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node --test --test-name-pattern="coincident" test/writer-fixed-point.test.mjs',
|
||
because: 'a positive wall authored over room masonry must be reconciled by its finish owner, '
|
||
+ 'including deterministic opening rehost, rather than left for Optimize (#477)',
|
||
patches: [{
|
||
file: 'src/writer-fixed-point.ts',
|
||
find: ' partitionIds: surviving,',
|
||
replace: ' partitionIds: [], // mutant: finish skips coincident seed scope',
|
||
}],
|
||
},
|
||
{
|
||
id: 'pair-recovery-config-writer-skips-fence',
|
||
guard: 'node scripts/backend-test-guard.mjs '
|
||
+ 'issue_491_config_writer_resolves_pending_pair_before_cas '
|
||
+ 'tests_backend/test_ha_websocket.py',
|
||
because: 'a config save after a half-finished Optimize must resolve the durable pair before '
|
||
+ 'CAS, or it can consume the recovery intent and commit over mixed state (#491 AC3)',
|
||
patches: [{
|
||
file: 'custom_components/houseplan/websocket_api.py',
|
||
find: ' async with rt.write_lock:\n'
|
||
+ ' resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)\n'
|
||
+ ' if resolved is None:\n'
|
||
+ ' return\n'
|
||
+ ' data = resolved.config_data\n'
|
||
+ ' current_rev = data.get("rev", 0)\n',
|
||
replace: ' async with rt.write_lock:\n'
|
||
+ ' data = await rt.config_store.async_load() or {}\n'
|
||
+ ' current_rev = data.get("rev", 0)\n',
|
||
}],
|
||
},
|
||
{
|
||
id: 'pair-recovery-point-writer-skips-fence',
|
||
guard: 'node scripts/backend-test-guard.mjs '
|
||
+ 'issue_491_point_layout_writer_applies_delta_to_recovered_pair '
|
||
+ 'tests_backend/test_ha_websocket.py',
|
||
because: 'a point drag must apply its one-device delta to the recovered target layout, not '
|
||
+ 'the stale visible half that existed when the drag started (#491 AC4)',
|
||
patches: [{
|
||
file: 'custom_components/houseplan/websocket_api.py',
|
||
find: ' resolved = await _resolved_write_pair(hass, connection, msg["id"], rt)\n'
|
||
+ ' if resolved is None:\n'
|
||
+ ' return\n'
|
||
+ ' config_data = resolved.config_data\n'
|
||
+ ' config = config_data.get("config") or {}\n'
|
||
+ ' markers = config.get("markers") or []\n',
|
||
replace: ' config_data = await rt.config_store.async_load() or {}\n'
|
||
+ ' resolved = ResolvedStorePair(\n'
|
||
+ ' config_data=config_data, layout_data=await rt.store.async_load() or {},\n'
|
||
+ ' )\n'
|
||
+ ' config = config_data.get("config") or {}\n'
|
||
+ ' markers = config.get("markers") or []\n',
|
||
}],
|
||
},
|
||
{
|
||
id: 'pair-recovery-fence-ignores-resolution-failure',
|
||
guard: 'node scripts/backend-test-guard.mjs '
|
||
+ 'issue_491_failed_fence_blocks_point_write_and_keeps_intent '
|
||
+ 'tests_backend/test_ha_websocket.py',
|
||
because: 'an unresolved durable pair must block every following writer; continuing with the '
|
||
+ 'raw halves would let an ordinary point update overwrite recovery state (#491 AC6)',
|
||
patches: [{
|
||
file: 'custom_components/houseplan/websocket_api.py',
|
||
find: ' except Exception: # noqa: BLE001 - the pending intent must remain authoritative\n'
|
||
+ ' _LOGGER.exception("House Plan: an interrupted paired write is still pending recovery")\n'
|
||
+ ' connection.send_error(\n'
|
||
+ ' msg_id,\n'
|
||
+ ' "commit_failed",\n'
|
||
+ ' "A previous House Plan save is pending recovery; retry or restart Home Assistant",\n'
|
||
+ ' )\n'
|
||
+ ' return None\n',
|
||
replace: ' except Exception: # mutant: ignore failed recovery and expose mixed halves\n'
|
||
+ ' resolved = ResolvedStorePair(\n'
|
||
+ ' config_data=await rt.config_store.async_load() or {},\n'
|
||
+ ' layout_data=await rt.store.async_load() or {},\n'
|
||
+ ' )\n',
|
||
}],
|
||
},
|
||
{
|
||
id: 'optimize-skips-pair-retry-rollback',
|
||
guard: 'node scripts/backend-test-guard.mjs '
|
||
+ 'issue_491_optimize_failure_restores_before_pair '
|
||
+ 'tests_backend/test_ha_websocket.py',
|
||
because: 'Optimize must report a failed target only after the common retry/rollback protocol '
|
||
+ 'has restored the exact before-pair and metadata (#491 AC1)',
|
||
patches: [{
|
||
file: 'custom_components/houseplan/websocket_api.py',
|
||
find: ' try:\n'
|
||
+ ' await _commit_pair(rt, pending, rollback)\n'
|
||
+ ' except PairCommitFailure as err:\n'
|
||
+ ' message = (\n'
|
||
+ ' "Plan optimization failed; the previous plan is pending recovery"\n',
|
||
replace: ' try:\n'
|
||
+ ' await _persist_pair_intent(rt, pending)\n'
|
||
+ ' await _converge_pair(rt, pending)\n'
|
||
+ ' except PairCommitFailure as err:\n'
|
||
+ ' message = (\n'
|
||
+ ' "Plan optimization failed; the previous plan is pending recovery"\n',
|
||
}],
|
||
},
|
||
{
|
||
id: 'optimize-undo-skips-pair-retry-rollback',
|
||
guard: 'node scripts/backend-test-guard.mjs '
|
||
+ 'issue_491_optimize_undo_failure_restores_pre_undo_pair '
|
||
+ 'tests_backend/test_ha_websocket.py',
|
||
because: 'Optimize Undo needs the same retry/rollback primitive; otherwise a failed restore '
|
||
+ 'can leave a target intent over the still-live optimized pair (#491 AC2)',
|
||
patches: [{
|
||
file: 'custom_components/houseplan/websocket_api.py',
|
||
find: ' try:\n'
|
||
+ ' await _commit_pair(rt, pending, rollback)\n'
|
||
+ ' except PairCommitFailure as err:\n'
|
||
+ ' message = (\n'
|
||
+ ' "Plan undo failed; the previous plan is pending recovery"\n',
|
||
replace: ' try:\n'
|
||
+ ' await _persist_pair_intent(rt, pending)\n'
|
||
+ ' await _converge_pair(rt, pending)\n'
|
||
+ ' except PairCommitFailure as err:\n'
|
||
+ ' message = (\n'
|
||
+ ' "Plan undo failed; the previous plan is pending recovery"\n',
|
||
}],
|
||
},
|
||
{
|
||
id: 'summary-picker-renders-unbounded-results',
|
||
guard: 'node demo/smoke_summary_panel.mjs',
|
||
because: 'the one active picker may search all HA entities but must never project all 10,000 '
|
||
+ 'matches into the modal DOM (#493 AC1/AC9)',
|
||
patches: [{
|
||
file: 'src/summary-panel-picker.ts',
|
||
find: ' limit = SUMMARY_ENTITY_RESULT_LIMIT,',
|
||
replace: ' limit = 10_000, // mutant: return the complete index to the DOM',
|
||
}],
|
||
},
|
||
{
|
||
id: 'summary-index-rebuilds-on-state-value',
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node --test --test-name-pattern="#493 entity index" test/summary-panel.test.mjs',
|
||
because: 'ordinary HA state values must reuse the entity-id/friendly-name index instead of '
|
||
+ 'sorting 10,000 entries on every update (#493 AC3)',
|
||
patches: [{
|
||
file: 'src/summary-panel-picker.ts',
|
||
find: ' if (unchanged) return previous;',
|
||
replace: ' if (false && unchanged) return previous; // mutant: rebuild every update',
|
||
}],
|
||
},
|
||
{
|
||
id: 'summary-picker-hot-add-stays-filtered',
|
||
guard: 'node demo/smoke_summary_panel.mjs',
|
||
because: 'an open picker must wake the otherwise dependency-filtered card when an unplaced '
|
||
+ 'HA entity is added, removed or renamed (#493 AC3)',
|
||
patches: [{
|
||
file: 'src/houseplan-card.ts',
|
||
find: ' if (!render && !this._summary?.observeHassComposition()) return;',
|
||
replace: ' if (!render) return; // mutant: the open picker never sees unplaced hot-adds',
|
||
}],
|
||
},
|
||
{
|
||
id: 'summary-local-scale-authority-disabled',
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node --test --test-name-pattern="#493 same-key scale" test/summary-panel-runtime.test.mjs',
|
||
because: 'a resolved summary-local key must shield its 200%/150% scales from the legacy '
|
||
+ 'kiosk seed during repeated setConfig (#493 AC4)',
|
||
patches: [{
|
||
file: 'src/summary-panel-runtime-loaded.ts',
|
||
find: ' this.host._kioskScale = { icon: this.local.icon_scale, font: this.local.font_scale };\n'
|
||
+ ' return true;\n',
|
||
replace: ' this.host._kioskScale = { icon: this.local.icon_scale, font: this.local.font_scale };\n'
|
||
+ ' return false; // mutant: allow setConfig to overwrite the resolved local scale\n',
|
||
}],
|
||
},
|
||
{
|
||
id: 'summary-picker-uses-row-index-owner',
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node --test --test-name-pattern="#493 active picker" test/summary-panel-runtime.test.mjs',
|
||
because: 'reordering an open summary row must not redirect the next selection to its old '
|
||
+ 'array index (#493 AC2)',
|
||
patches: [{
|
||
file: 'src/summary-panel-runtime-loaded.ts',
|
||
find: ' const value = draft.blocks.find((block) => block.id === blockId)\n'
|
||
+ ' ?.values.find((candidate) => candidate.id === valueId);',
|
||
replace: ' const value = draft.blocks[0]?.values[0]; // mutant: stale row-index ownership',
|
||
}],
|
||
},
|
||
{
|
||
id: 'summary-lifecycle-generation-disabled',
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||
+ '&& node --test --test-name-pattern="#493 a late save" test/summary-panel-runtime.test.mjs',
|
||
because: 'an in-flight save from the previous HA user may finish server-side but must never '
|
||
+ 'adopt its config or local choice into the new lifecycle (#493 AC5)',
|
||
patches: [{
|
||
file: 'src/summary-panel-runtime-loaded.ts',
|
||
find: ' return this.connected && generation === this.lifecycleGeneration\n'
|
||
+ ' && this.lifecycleIdentity === this.identity();',
|
||
replace: ' return this.connected; // mutant: every async completion belongs to the current user',
|
||
}],
|
||
},
|
||
{
|
||
id: 'summary-optimize-skips-ordinary-writer-guard',
|
||
guard: 'node scripts/backend-test-guard.mjs '
|
||
+ 'test_493_ordinary_writers_share_summary_panel_contract '
|
||
+ 'tests_backend/test_ha_websocket.py',
|
||
because: 'Optimize must preserve an omitted summary namespace and enforce the same readable '
|
||
+ 'reference policy as config/set (#493 AC7)',
|
||
patches: [{
|
||
file: 'custom_components/houseplan/websocket_api.py',
|
||
find: ' checked = prepare_ordinary_summary_candidate(\n'
|
||
+ ' msg["config"], config_data.get("config"), readable_entity_ids, _normalize,\n'
|
||
+ ' )',
|
||
replace: ' checked = _normalize(msg["config"]) # mutant: Optimize bypasses summary guard',
|
||
}],
|
||
},
|
||
{
|
||
id: 'import-apply-rechecks-preview-after-commit',
|
||
guard: 'node scripts/backend-test-guard.mjs '
|
||
+ 'issue_495_apply_result '
|
||
+ 'tests_backend/test_ha_import_export.py',
|
||
because: 'once both halves are durable, a lapsed TTL or an eviction by a newer preview must '
|
||
+ 'not turn the accepted import into an error (#495 AC1/AC2)',
|
||
patches: [{
|
||
file: 'custom_components/houseplan/websocket_api.py',
|
||
find: ' rt.import_previews.pop(msg["token"], None)\n',
|
||
replace: ' get_candidate(rt, msg["token"], _connection_user_id(connection), consume=True)\n',
|
||
}],
|
||
},
|
||
{
|
||
id: 'trail-purge-drops-routes-in-memory-only',
|
||
guard: 'node scripts/backend-test-guard.mjs '
|
||
+ 'issue_495_dropped_route_runs_reach_the_store '
|
||
+ 'tests_backend/test_trail_recorder.py',
|
||
because: 'a route deleted from a live marker without any orphan must still write the book, or a '
|
||
+ 'restart brings its runs back (#495 AC3)',
|
||
patches: [{
|
||
file: 'custom_components/houseplan/trails.py',
|
||
find: ' removed = 0\n await self._save_now_locked()\n',
|
||
replace: ' removed = 0 # mutant: memory only\n',
|
||
}],
|
||
},
|
||
{
|
||
id: 'trail-purge-keeps-dropped-runs-after-failed-write',
|
||
guard: 'node scripts/backend-test-guard.mjs '
|
||
+ 'issue_495_dropped_route_runs_roll_back '
|
||
+ 'tests_backend/test_trail_recorder.py',
|
||
because: 'when the store refuses the write the dropped runs must return to memory so the next '
|
||
+ 'commit retries instead of diverging from the disk (#495 AC4)',
|
||
patches: [{
|
||
file: 'custom_components/houseplan/trails.py',
|
||
find: ' self._restore_dropped(dropped)\n raise\n',
|
||
replace: ' raise # mutant: memory keeps the drop the disk never saw\n',
|
||
}],
|
||
},
|
||
{
|
||
id: 'trail-purge-forgets-routes-when-orphans-exist',
|
||
guard: 'node scripts/backend-test-guard.mjs '
|
||
+ 'issue_495_dropped_route_runs_share_the_orphan_transaction '
|
||
+ 'tests_backend/test_trail_recorder.py',
|
||
because: 'orphan deletion and route drops are one transaction: the orphan path must not write a '
|
||
+ 'book that still holds the dropped runs (#495 AC5)',
|
||
patches: [{
|
||
file: 'custom_components/houseplan/trails.py',
|
||
find: ' if orphan_ids:\n removed = await self._delete_many_locked(orphan_ids)\n',
|
||
replace: ' if orphan_ids:\n self._restore_dropped(dropped) # mutant\n'
|
||
+ ' removed = await self._delete_many_locked(orphan_ids)\n',
|
||
}],
|
||
},
|
||
{
|
||
id: 'trail-purge-failed-orphan-write-loses-dropped-runs',
|
||
guard: 'node scripts/backend-test-guard.mjs '
|
||
+ 'issue_495_failed_orphan_transaction_restores_dropped_route_runs_too '
|
||
+ 'tests_backend/test_trail_recorder.py',
|
||
because: 'the #335 rollback of the orphan transaction must cover the route drops made in the same '
|
||
+ 'pass, not only the orphan books (#495 AC4)',
|
||
patches: [{
|
||
file: 'custom_components/houseplan/trails.py',
|
||
find: ' except Exception:\n'
|
||
+ ' # The store is the durable authority (#335): a drop that\n',
|
||
replace: ' except Exception:\n'
|
||
+ ' dropped = {} # mutant: nothing to restore\n'
|
||
+ ' # The store is the durable authority (#335): a drop that\n',
|
||
}],
|
||
},
|
||
{
|
||
id: 'release-gate-counts-cancelled-runs',
|
||
guard: 'node --test test/release-gate.test.mjs',
|
||
because: 'a cancelled twin on the tag SHA proves nothing and must not block the assets; the '
|
||
+ 'verdict is the latest non-cancelled run (#511 AC1)',
|
||
patches: [{
|
||
file: 'scripts/release-gate.mjs',
|
||
find: " const relevant = (Array.isArray(runs) ? runs : []).filter((run) => run && run.conclusion !== 'cancelled');",
|
||
replace: " const relevant = (Array.isArray(runs) ? runs : []).filter((run) => !!run); // mutant: cancelled counts",
|
||
}],
|
||
},
|
||
{
|
||
id: 'release-gate-oldest-run-wins',
|
||
guard: 'node --test test/release-gate.test.mjs',
|
||
because: 'the latest run is the verdict: a re-run or another-baseline comparison must be able to '
|
||
+ 'refresh an older result on the same SHA (#511 AC1)',
|
||
patches: [{
|
||
file: 'scripts/release-gate.mjs',
|
||
find: " return relevant.sort((a, b) => stamp(b) - stamp(a) || Number(b.id || 0) - Number(a.id || 0))[0] || null;",
|
||
replace: " return relevant.sort((a, b) => stamp(a) - stamp(b) || Number(a.id || 0) - Number(b.id || 0))[0] || null; // mutant: oldest",
|
||
}],
|
||
},
|
||
{
|
||
id: 'summary-first-paint-shows-unavailable',
|
||
guard: 'node demo/smoke_summary_first_paint.mjs',
|
||
because: 'до прихода ленивого чанка метрик значения ещё НЕ известны: текст «источник недоступен» '
|
||
+ 'здесь — ложь, которую человек видит в первый же кадр панели (#509 AC1)',
|
||
patches: [{
|
||
file: 'src/summary-panel-runtime-loaded.ts',
|
||
find: " if (!module) return { kind: 'pending' };",
|
||
replace: " if (!module) return { kind: 'unavailable' }; // mutant: loading looks like a dead source",
|
||
}],
|
||
},
|
||
{
|
||
id: 'summary-metrics-block-first-frame',
|
||
guard: 'node demo/smoke_summary_first_paint.mjs',
|
||
because: 'агрегаты по всей геометрии и реестру, посчитанные внутри render, замораживают первый '
|
||
+ 'показ панели на сотни миллисекунд — ровно симптом #509 (AC4)',
|
||
patches: [{
|
||
file: 'src/summary-panel-runtime-loaded.ts',
|
||
find: " const needsAggregate = value.source.key === 'device_count' || value.source.key === 'total_area';",
|
||
replace: " this.computeMetrics(); // mutant: aggregates back inside render\n const needsAggregate = value.source.key === 'device_count' || value.source.key === 'total_area';",
|
||
}],
|
||
},
|
||
{
|
||
id: 'summary-area-recomputes-walls-per-room',
|
||
guard: 'node --test test/summary-panel.test.mjs',
|
||
because: 'innerContourForRoom без общей геометрии объединяет кладку пространства заново для КАЖДОЙ '
|
||
+ 'комнаты: 176 мс на комнату и 11 с на большом плане (#509 AC3)',
|
||
patches: [{
|
||
file: 'src/summary-panel-metrics.ts',
|
||
find: " shared.roomGeom, shared.multiWallNodes,",
|
||
replace: " // mutant: no shared masonry",
|
||
}],
|
||
},
|
||
{
|
||
id: 'summary-stale-metric-falls-back-to-skeleton',
|
||
guard: 'node --test test/summary-panel-runtime.test.mjs',
|
||
because: 'правка конфигурации не должна возвращать панель в скелет: прежнее число остаётся до '
|
||
+ 'прихода нового, иначе мигание возвращается с другой стороны (#509 AC9)',
|
||
patches: [{
|
||
file: 'src/summary-panel-runtime-loaded.ts',
|
||
find: " if (needsAggregate && !known) {",
|
||
replace: " if (needsAggregate && (!known || !this.metricsFresh())) {",
|
||
}],
|
||
},
|
||
{
|
||
id: 'summary-dialog-drops-flex-content',
|
||
guard: 'node demo/smoke_summary_dialog_scroll.mjs',
|
||
because: 'in Home Assistant the settings dialog scrolls only because ha-dialog lays its body out as a '
|
||
+ 'flex column; without flex-content the editor is unbounded and wheel/touch die at it (#508 AC1)',
|
||
patches: [{
|
||
file: 'src/summary-panel-editor.ts',
|
||
find: " .title=${t('summary.settings')} wide flex-content",
|
||
replace: " .title=${t('summary.settings')} wide",
|
||
}],
|
||
},
|
||
{
|
||
id: 'hp-dialog-ignores-flex-content',
|
||
guard: 'node demo/smoke_summary_dialog_scroll.mjs',
|
||
because: 'the attribute is only a promise until hp-dialog forwards it to ha-dialog; a shell that '
|
||
+ 'swallows flex-content leaves the HA branch exactly as broken as before (#508 AC1)',
|
||
patches: [{
|
||
file: 'src/hp-dialog.ts',
|
||
find: " ?flexcontent=${this.flexContent}\n .preventScrimClose=${!this.dismissOnScrim}\n .ariaLabelledBy=${this._titleId}\n @opened=${this._focusInitial}",
|
||
replace: " .preventScrimClose=${!this.dismissOnScrim}\n .ariaLabelledBy=${this._titleId}\n @opened=${this._focusInitial}",
|
||
}],
|
||
},
|
||
{
|
||
id: 'version-seam-ignores-override',
|
||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test test/card-version.test.mjs',
|
||
because: 'the harness pins the displayed version through the seam; a seam that always returns '
|
||
+ 'the literal puts every beta bump back into the golden frames (#512 AC1)',
|
||
patches: [{
|
||
file: 'src/card-version.ts',
|
||
find: " return typeof override === 'string' && override.length > 0 ? override : fallback;",
|
||
replace: ' return fallback; // mutant: seam ignored',
|
||
}],
|
||
},
|
||
{
|
||
id: 'docs-identical-accepts-any-frame',
|
||
guard: 'node --test test/png-identical.test.mjs',
|
||
because: 'the identical-pixels acceptance must count every differing pixel, alpha included; a '
|
||
+ 'comparator that reports zero would accept a changed frame without review (#512 AC3)',
|
||
patches: [{
|
||
file: 'scripts/png-identical.mjs',
|
||
find: " || a.data[at + 2] !== b.data[at + 2] || a.data[at + 3] !== b.data[at + 3]) differing += 1;",
|
||
replace: " || a.data[at + 2] !== b.data[at + 2]) differing += 0; // mutant: nothing differs",
|
||
}],
|
||
},
|
||
{
|
||
id: 'summary-runtime-attaches-after-first-render',
|
||
guard: 'node demo/smoke_summary_warm_attach.mjs',
|
||
because: 'a warm summary chunk must hand the new instance its runtime synchronously in '
|
||
+ 'connectedCallback; deferring even a warm factory to a promise lets the first header '
|
||
+ 'measurement miss the controls and a late refit follows (#506 AC4/AC5)',
|
||
patches: [{
|
||
file: 'src/summary-runtime-loader.ts',
|
||
find: ' const warm = this.create(host);\n'
|
||
+ ' if (warm) {\n'
|
||
+ ' ready(warm);\n'
|
||
+ ' return attachment;\n'
|
||
+ ' }\n',
|
||
replace: ' // mutant: the warm factory is deferred like a cold import\n',
|
||
}],
|
||
},
|
||
{
|
||
id: 'mutants-run-on-every-push',
|
||
guard: 'node --test --test-name-pattern="#510" test/classify-changes.test.mjs',
|
||
because: 'mutants by diff belong to the review candidate, the PR, the nightly run and the beta '
|
||
+ 'candidate — an ordinary push must not spend 3×8 runner minutes on them (#510 AC1)',
|
||
patches: [{
|
||
file: 'scripts/classify-changes.mjs',
|
||
find: " if (eventName === 'workflow_dispatch') return String(fullInput) === 'true' || String(mutantsInput) === 'true';\n return hasReleaseTrailer(headMessage);\n}",
|
||
replace: " if (eventName === 'workflow_dispatch') return String(fullInput) === 'true' || String(mutantsInput) === 'true';\n return true; // mutant: every push\n}",
|
||
}],
|
||
},
|
||
{
|
||
id: 'review-starts-on-red-validate',
|
||
guard: 'node --test test/validate-gate.test.mjs',
|
||
because: 'a red dispatch run on the material must return the task without a review; treating '
|
||
+ 'any completed run as green spends the review cycle on code CI already rejected (#510 AC2)',
|
||
patches: [{
|
||
file: 'scripts/validate-gate.mjs',
|
||
find: " if (run.conclusion !== 'success') return { result: 'red', url: run.url, note: `dispatch-прогон завершился: ${run.conclusion}` };",
|
||
replace: " // mutant: completed means green — a red dispatch falls through to the job check",
|
||
}],
|
||
},
|
||
{
|
||
id: 'review-anchor-drops-issue-body',
|
||
guard: 'node --test test/review-doc-guard.test.mjs',
|
||
because: 'ТЗ живёт в теле issue, которое GitHub правит без diff и без истории: без хеша тела '
|
||
+ 'в якорях доказать «вердикт вынесен на этом тексте» нечем (#517 AC1)',
|
||
patches: [{
|
||
file: 'scripts/review-doc-guard.mjs',
|
||
find: ' lines.push(`- Тело issue: \\`${issueBody}\\``);',
|
||
replace: ' // mutant: anchor without the issue body',
|
||
}],
|
||
},
|
||
{
|
||
id: 'review-ignores-changed-spec-body',
|
||
guard: 'node --test test/review-doc-guard.test.mjs',
|
||
because: 'правка ТЗ после зелёного ревью ТЗ обязана приходить ревьюеру кода находкой; '
|
||
+ 'сравнение, которое всегда молчит, возвращает ровно ту слепоту, ради которой заведён хеш (#517 AC2)',
|
||
patches: [{
|
||
file: 'scripts/review-doc-guard.mjs',
|
||
find: ' return recorded === digest ? null : { doc: green.name, recorded, current: digest };',
|
||
replace: ' return null; // mutant: the body never changed',
|
||
}],
|
||
},
|
||
{
|
||
id: 'reuse-ignores-changed-issue-body',
|
||
guard: 'node --test test/review-doc-guard.test.mjs',
|
||
because: 'повторное применение зелёного вердикта пропускает вызов модели целиком: если оно не '
|
||
+ 'смотрит на хеш тела, правка ТЗ между раундами проходит невидимой (#517 AC6)',
|
||
patches: [{
|
||
file: 'scripts/review-doc-guard.mjs',
|
||
find: ' if (recordedBody && issueBodyDigest && recordedBody !== issueBodyDigest) return null;',
|
||
replace: ' // mutant: reuse ignores the issue body',
|
||
}],
|
||
},
|
||
{
|
||
id: 'process-gate-requires-spec-file',
|
||
guard: 'node --test test/process-gate.test.mjs',
|
||
because: 'после #517 файла ТЗ не создаёт ни одна новая задача: гейт, требующий файл, краснеет '
|
||
+ 'на каждом классе A и учит игнорировать себя (#517 AC3)',
|
||
patches: [{
|
||
file: 'scripts/process-gate.mjs',
|
||
find: ' if (hasSpecText(body)) continue;',
|
||
replace: ' if (body !== null) continue; // mutant: only the file counts',
|
||
}],
|
||
},
|
||
{
|
||
id: 'review-returns-task-on-cancelled-dispatch',
|
||
guard: 'node --test test/validate-gate.test.mjs',
|
||
because: 'a dispatch cancelled by its replacement in the same concurrency group proves nothing; '
|
||
+ 'reading it as red sends the task back to S6 for no reason (#510 review r1 M1, #511)',
|
||
patches: [{
|
||
file: 'scripts/validate-gate.mjs',
|
||
find: " if (run.conclusion === 'cancelled') {",
|
||
replace: " if (false) { // mutant: cancelled counts as red",
|
||
}],
|
||
},
|
||
{
|
||
id: 'merge-rereviews-own-review-doc',
|
||
guard: 'node --test test/merge-candidate.test.mjs',
|
||
because: 'the candidate carries its own review document and the material does not; a patch-id '
|
||
+ 'that counts docs/reviews sends every green candidate back to review whenever dev moved (#516)',
|
||
patches: [{
|
||
file: 'scripts/merge-candidate.mjs',
|
||
find: " const diff = must(git('diff', '--full-index', from, to, '--', '.', ':!docs/reviews'), 'diff');",
|
||
replace: " const diff = must(git('diff', '--full-index', from, to), 'diff'); // mutant: review docs count",
|
||
}],
|
||
},
|
||
{
|
||
id: 'merge-trusts-cancelled-dispatch',
|
||
guard: 'node --test test/merge-candidate.test.mjs',
|
||
because: 'the real waitValidate must skip a dispatch cancelled by its replacement; reading it as red '
|
||
+ 'fails the merge candidate for nothing (#510 review r2 M1, #511)',
|
||
patches: [{
|
||
file: 'scripts/merge-candidate.mjs',
|
||
find: " const runs = all.filter((x) => (!event || x.event === event) && x.conclusion !== 'cancelled');",
|
||
replace: " const runs = all.filter((x) => (!event || x.event === event)); // mutant: cancelled is red",
|
||
}],
|
||
},
|
||
{
|
||
id: 'release-ships-on-red-e2e',
|
||
guard: 'node --test test/e2e-gate.test.mjs',
|
||
because: 'a stable release must wait for a green E2E on a real Home Assistant; a gate that reads '
|
||
+ 'a failed run as green ships the assets the run just rejected (#514 AC1)',
|
||
patches: [{
|
||
file: 'scripts/e2e-gate.mjs',
|
||
find: " if (run.conclusion === 'success') return { result: 'green', url: run.url, note: `E2E на ${tag} зелёный` };",
|
||
replace: " return { result: 'green', url: run.url, note: `E2E на ${tag} зелёный` }; // mutant: completed means green",
|
||
}],
|
||
},
|
||
{
|
||
id: 'release-upgrades-stable-onto-itself',
|
||
guard: 'node --test test/e2e-gate.test.mjs',
|
||
because: 'at release time the tag under test is already the newest stable, so upgrade_from=stable '
|
||
+ 'makes the upgrade suite update a version onto itself and go red (#514, live run 09.09)',
|
||
patches: [{
|
||
file: 'scripts/e2e-gate.mjs',
|
||
find: " return prior[0]?.tagName || 'stable';",
|
||
replace: " return 'stable'; // mutant: always the newest stable, i.e. the tag itself",
|
||
}],
|
||
},
|
||
{
|
||
id: 'release-trusts-foreign-e2e-run',
|
||
guard: 'node --test test/e2e-gate.test.mjs',
|
||
because: 'the gate must follow the dispatch it made for this tag; accepting any dispatch run lets '
|
||
+ 'a green run on another tag vouch for this release (#514 AC2)',
|
||
patches: [{
|
||
file: 'scripts/e2e-gate.mjs',
|
||
find: " return (Array.isArray(jobs) ? jobs : []).some((job) => needles.some((needle) => String(job?.name || '').startsWith(needle)));",
|
||
replace: " return true; // mutant: every dispatch is ours",
|
||
}],
|
||
},
|
||
{
|
||
id: 'review-trusts-push-run-without-mutants',
|
||
guard: 'node --test test/validate-gate.test.mjs',
|
||
because: 'a green push run on the same SHA holds no mutants and is not proof; the gate must '
|
||
+ 'dispatch the mutant run instead of accepting it (#510 AC2)',
|
||
patches: [{
|
||
file: 'scripts/validate-gate.mjs',
|
||
find: " return run?.event === 'workflow_dispatch';",
|
||
replace: " return !!run; // mutant: any run counts",
|
||
}],
|
||
},
|
||
{
|
||
id: 'merge-waits-push-run-without-mutants',
|
||
guard: 'node --test test/merge-candidate.test.mjs',
|
||
because: 'the merged candidate is a new tree; the merge must dispatch the mutant run on it and '
|
||
+ 'wait for that run, not for the push run that carries no mutants (#510 AC3)',
|
||
patches: [{
|
||
file: 'scripts/merge-candidate.mjs',
|
||
find: " ops.dispatchValidate(branch);\n ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);\n const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });",
|
||
replace: " ops.log(`Validate на кандидате ${candidate.slice(0, 8)} — ждём`);\n const { result, url } = await ops.waitValidate(candidate); // mutant: push run, no dispatch",
|
||
}],
|
||
},
|
||
{
|
||
id: 'quota-counts-the-staged-upload-twice',
|
||
guard: 'node scripts/backend-test-guard.mjs '
|
||
+ 'issue_498_upload_accepts_the_last_bytes '
|
||
+ 'tests_backend/test_ha_upload.py',
|
||
because: 'the staged .upload-* already lives under files_root; charging it as stored usage '
|
||
+ 'and as incoming refuses the last file that still fits (#498 AC1)',
|
||
patches: [{
|
||
file: 'custom_components/houseplan/http_api.py',
|
||
find: ' MAX_FILES_BYTES, MAX_FILES_COUNT, exclude=tmp_path,\n',
|
||
replace: ' MAX_FILES_BYTES, MAX_FILES_COUNT,\n',
|
||
}],
|
||
},
|
||
{
|
||
id: 'quota-ignores-foreign-staged-uploads',
|
||
guard: 'node scripts/backend-test-guard.mjs '
|
||
+ 'issue_498_concurrent_uploads_still_count_each_other '
|
||
+ 'tests_backend/test_ha_upload.py',
|
||
because: 'only the caller\'s own staged file is exempt: skipping every .upload-* would let two '
|
||
+ 'concurrent uploads pass a quota neither of them fits alone (#498 AC1)',
|
||
patches: [{
|
||
file: 'custom_components/houseplan/plans.py',
|
||
find: ' if exclude is not None and item == exclude:\n',
|
||
replace: ' if item.name.startswith(TMP_PREFIX): # mutant: every staged file is invisible\n',
|
||
}],
|
||
},
|
||
{
|
||
id: 'support-palette-copies-any-key',
|
||
guard: 'node scripts/backend-test-guard.mjs '
|
||
+ 'rich_plan_projection_preserves_safe_structure '
|
||
+ 'tests_backend/test_support_package.py',
|
||
because: 'the package must carry the fill palette only under the slot names the card defines; '
|
||
+ 'a private string used as a key must not leave the installation (#498 AC2)',
|
||
patches: [{
|
||
file: 'custom_components/houseplan/support_package.py',
|
||
find: ' for key in SUPPORT_FILL_COLOR_KEYS\n',
|
||
replace: ' for key in fill_colors\n',
|
||
}],
|
||
},
|
||
{
|
||
id: 'svg-reference-chain-unbounded',
|
||
guard: 'node scripts/backend-test-guard.mjs '
|
||
+ 'issue_498_flat_reference_chain_is_bounded '
|
||
+ 'tests_backend/test_decor_assets.py',
|
||
because: 'a reference chain must stop at MAX_SVG_REF_DEPTH with too_large; an unbounded walk '
|
||
+ 'accepts what rendering will choke on (#498 AC3)',
|
||
patches: [{
|
||
file: 'custom_components/houseplan/decor_assets.py',
|
||
find: ' if chain > MAX_SVG_REF_DEPTH:\n',
|
||
replace: ' if False: # mutant: no chain limit\n',
|
||
}, {
|
||
file: 'custom_components/houseplan/decor_assets.py',
|
||
find: ' if len(stack) > MAX_SVG_REF_DEPTH:\n',
|
||
replace: ' if False: # mutant: no stack limit\n',
|
||
}],
|
||
},
|
||
{
|
||
id: 'svg-reference-depth-per-start-not-per-chain',
|
||
guard: 'node scripts/backend-test-guard.mjs '
|
||
+ 'issue_498_flat_reference_chain_is_bounded '
|
||
+ 'tests_backend/test_decor_assets.py',
|
||
because: 'the limit is the longest chain through a node; measuring only the stack of the '
|
||
+ 'traversal that reached it first lets a hostile id order cut a long chain into short '
|
||
+ 'segments (#498 spec review r1)',
|
||
patches: [{
|
||
file: 'custom_components/houseplan/decor_assets.py',
|
||
find: ' stack[-1] = (node_id, children, max(chain, longest[ref] + 1))\n',
|
||
replace: ' stack[-1] = (node_id, children, chain) # mutant: forget the memoised chain\n',
|
||
}],
|
||
},
|
||
{
|
||
id: 'svg-reference-walk-recursive-again',
|
||
guard: 'node scripts/backend-test-guard.mjs '
|
||
+ 'issue_498_flat_reference_chain_is_bounded '
|
||
+ 'tests_backend/test_decor_assets.py',
|
||
because: 'the walk must be iterative: a recursive DFS over a flat 2500-link chain dies with '
|
||
+ 'RecursionError, which is not a DecorAssetError and reaches the client as a 500 (#498 AC3)',
|
||
patches: [{
|
||
file: 'custom_components/houseplan/decor_assets.py',
|
||
find: ' _walk_reference_graph(ids, ref_graph)\n',
|
||
replace: ' visiting: set[str] = set()\n'
|
||
+ ' visited: set[str] = set()\n'
|
||
+ '\n'
|
||
+ ' def _visit(node_id: str) -> None: # mutant: the old recursive walk\n'
|
||
+ ' if node_id in visiting:\n'
|
||
+ ' raise DecorAssetError("invalid_image", "The SVG contains a cyclic local reference")\n'
|
||
+ ' if node_id in visited:\n'
|
||
+ ' return\n'
|
||
+ ' visiting.add(node_id)\n'
|
||
+ ' for ref in ref_graph.get(node_id, ()):\n'
|
||
+ ' _visit(ref)\n'
|
||
+ ' visiting.remove(node_id)\n'
|
||
+ ' visited.add(node_id)\n'
|
||
+ '\n'
|
||
+ ' for node_id in ids:\n'
|
||
+ ' _visit(node_id)\n',
|
||
}],
|
||
},
|
||
{
|
||
id: 'config-adoption-echo-clears-history',
|
||
guard: 'node --test test/config-adoption.test.mjs',
|
||
because: 'an authoritative echo with identical content must keep local geometry undo and the '
|
||
+ 'reactive root; only a genuinely different baseline retires them (#500 AC3, I3)',
|
||
patches: [{
|
||
file: 'src/config-adoption.ts',
|
||
find: ' const configChanged = nextFingerprint !== this.currentConfigFingerprint();\n'
|
||
+ ' if (configChanged) {\n'
|
||
+ ' onConfigReplace();',
|
||
replace: ' const configChanged = nextFingerprint !== this.currentConfigFingerprint();\n'
|
||
+ ' onConfigReplace();\n'
|
||
+ ' if (configChanged) {',
|
||
}],
|
||
},
|
||
{
|
||
id: 'config-adoption-rev-from-foreign-response',
|
||
guard: 'node --test test/config-adoption.test.mjs',
|
||
because: 'a revision is accepted only together with the body it describes; taking the reply '
|
||
+ 'revision without its body leaves a stale candidate with a newer rev (#500 I2, #490 F1)',
|
||
patches: [{
|
||
file: 'src/config-adoption.ts',
|
||
find: ' if (!assetReady) {\n'
|
||
+ " host._continuity.note('asset-failed');",
|
||
replace: ' if (!assetReady) {\n'
|
||
+ ' adoption.acceptConfigWrite(candidateConfig ?? adoption.config!, cfgResp ?? {});\n'
|
||
+ " host._continuity.note('asset-failed');",
|
||
}],
|
||
},
|
||
{
|
||
id: 'config-adoption-rollback-ignores-rev',
|
||
guard: 'node --test test/config-adoption.test.mjs',
|
||
because: 'a rollback must yield to a newer revision adopted while the candidate was in flight; '
|
||
+ 'comparing content alone would erase a conflict reload (#500 AC6, #439)',
|
||
patches: [{
|
||
file: 'src/config-adoption.ts',
|
||
find: ' if (!current || this.configRev !== attempt.revision\n'
|
||
+ ' || contentFingerprint(current) !== attempt.attemptedFingerprint) return false;',
|
||
replace: ' if (!current\n'
|
||
+ ' || contentFingerprint(current) !== attempt.attemptedFingerprint) return false;',
|
||
}],
|
||
},
|
||
{
|
||
id: 'post-write-tail-runs-on-refused-gate',
|
||
guard: 'node demo/smoke_post_write_adoption.mjs',
|
||
because: '#500 r1 M1: a post-write caller whose adoption was refused must skip its tail — '
|
||
+ 'clearing history, bumping the geometry epoch and toasting «отменено» on a body the card '
|
||
+ 'never adopted leaves the frontend describing state the server does not have; the scheduled '
|
||
+ 'reload owns the tail. Guarded by the smoke, so the review gate runs it (r2 M1: the witness '
|
||
+ 'was red for a round and nobody saw it — CI never ran this smoke)',
|
||
patches: [{
|
||
file: 'src/houseplan-editor-runtime.ts',
|
||
find: " if (adopted.status !== 'adopted') return; // asset wait: the scheduled reload owns the tail",
|
||
replace: ' void adopted; // mutant: the tail runs on a body that was never adopted',
|
||
}],
|
||
},
|
||
{
|
||
id: 'post-write-skips-asset-gate',
|
||
guard: 'node --test test/config-adoption.test.mjs',
|
||
because: 'post-write adoptions (space/delete, optimize_undo, import/apply) pass the same '
|
||
+ 'backdrop readiness gate as reloads; skipping it re-creates the transient blank plan of #490 M1 (#500 AC4)',
|
||
patches: [{
|
||
file: 'src/config-adoption.ts',
|
||
find: ' if (structuralChanged) {\n'
|
||
+ ' const assetReady = await host._signer.prepareImage(host.hass, host._candidateBackdrop(candidateConfig));',
|
||
replace: " if (structuralChanged && input.profile === 'reload') {\n"
|
||
+ ' const assetReady = await host._signer.prepareImage(host.hass, host._candidateBackdrop(candidateConfig));',
|
||
}],
|
||
},
|
||
{
|
||
id: 'config-reload-drops-asset-gap-ownership',
|
||
guard: 'node demo/smoke_config_reload_race.mjs',
|
||
because: 'a config response that was current before prepareImage may lose to a newer reload '
|
||
+ 'inside that await; omitting the request claim at the common adoption boundary lets the '
|
||
+ 'late response replace View, revision and cache again (#543 AC1, AC2, AC8)',
|
||
patches: [{
|
||
file: 'src/config-reload-authority.ts',
|
||
find: " profile: 'reload',\n"
|
||
+ ' isCurrent,\n'
|
||
+ " afterAdopt: () => { host._regSignature = ''; host._maybeRebuildDevices(); },",
|
||
replace: " profile: 'reload',\n"
|
||
+ ' // mutant: the common asset gate cannot see that this request lost ownership\n'
|
||
+ " afterAdopt: () => { host._regSignature = ''; host._maybeRebuildDevices(); },",
|
||
}],
|
||
},
|
||
{
|
||
id: 'radar-profile-source-entity-id-omitted',
|
||
guard: 'python3 -m pytest tests_backend/test_radar_validation.py -q -p no:cacheprovider '
|
||
+ '-k "stage1_source_inventory_is_exact_for_each_profile"',
|
||
because: 'range_v1 and zones_v1 read their primary value from entity_id; omitting that '
|
||
+ 'profile role removes both the HA subscription and the per-source read-permission check '
|
||
+ '(#545 AC1, AC4)',
|
||
patches: [{
|
||
file: 'custom_components/houseplan/radar_validation.py',
|
||
find: ' "range_v1": ("ranges", ("entity_id", "presence_entity")),',
|
||
replace: ' "range_v1": ("ranges", ("presence_entity",)),',
|
||
}, {
|
||
file: 'custom_components/houseplan/radar_validation.py',
|
||
find: ' "zones_v1": ("zones", ("entity_id",)),',
|
||
replace: ' "zones_v1": ("zones", ()),',
|
||
}],
|
||
},
|
||
];
|
||
|
||
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
|
||
const mutationEditorSource = readFileSync(join(repoRoot, 'src/houseplan-editor-runtime.ts'), 'utf8');
|
||
export const MUTANTS = MUTANT_DEFINITIONS.map((mutant) => ({
|
||
...mutant,
|
||
patches: mutant.patches.map((patch) => relocateEditorPatch(
|
||
patch, mutationCardSource, mutationEditorSource,
|
||
)),
|
||
}));
|
||
|
||
// --- механика ---------------------------------------------------------------
|
||
|
||
export function applyPatches(root, patches) {
|
||
for (const patch of patches) {
|
||
const path = join(root, patch.file);
|
||
const source = readFileSync(path, 'utf8');
|
||
const hits = source.split(patch.find).length - 1;
|
||
if (hits !== 1) {
|
||
throw new Error(`${patch.file}: якорь найден ${hits} раз(а), нужен ровно 1 — реестр отстал от кода`);
|
||
}
|
||
writeFileSync(path, source.replace(patch.find, patch.replace));
|
||
}
|
||
}
|
||
|
||
function sh(cmd, cwd, extraEnv = {}) {
|
||
return spawnSync(cmd, {
|
||
cwd, shell: true, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
||
env: { ...process.env, ...extraEnv },
|
||
});
|
||
}
|
||
|
||
function makeWorktree() {
|
||
const dir = mkdtempSync(join(tmpdir(), 'hp-mutant-'));
|
||
const added = spawnSync('git', ['-C', repoRoot, 'worktree', 'add', '--detach', dir, 'HEAD'],
|
||
{ encoding: 'utf8' });
|
||
if (added.status !== 0) throw new Error(`git worktree add: ${added.stderr}`);
|
||
// node_modules не копируется — символическая ссылка на настоящий. Установка
|
||
// зависимостей на каждого мутанта превратила бы вечерний гейт в суточный.
|
||
symlinkSync(join(repoRoot, 'node_modules'), join(dir, 'node_modules'), 'junction');
|
||
return dir;
|
||
}
|
||
|
||
function dropWorktree(dir) {
|
||
// On Windows a directory junction can be traversed by recursive worktree
|
||
// cleanup. Detach it first; otherwise removing the mutant may empty the real
|
||
// repository's node_modules target instead of deleting only the junction.
|
||
const modules = join(dir, 'node_modules');
|
||
if (existsSync(modules)) unlinkSync(modules);
|
||
spawnSync('git', ['-C', repoRoot, 'worktree', 'remove', '--force', dir], { encoding: 'utf8' });
|
||
rmSync(dir, { recursive: true, force: true });
|
||
}
|
||
|
||
/**
|
||
* Нужен ли гварду скомпилированный `test-build/` (#235).
|
||
*
|
||
* Гварды в реестре двух видов: длинные сами начинаются с
|
||
* `npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs`, короткие —
|
||
* сразу с `node --test`. В свежем worktree каталога `test-build/` нет вообще,
|
||
* поэтому короткий гвард падал с `ERR_MODULE_NOT_FOUND` — и это читалось как
|
||
* «мутант поймал поломку», хотя означало «гвард не смог исполниться». Компиляция
|
||
* стоит секунд, поэтому шаг ставится только там, где гвард не делает его сам.
|
||
*/
|
||
export function guardNeedsTestBuild(guard) {
|
||
return /(^|[\s&|;])node --test\b/.test(guard) && !guard.includes('tsconfig.test.json');
|
||
}
|
||
|
||
/**
|
||
* Нужен ли гварду СОБРАННЫЙ бандл (#332).
|
||
*
|
||
* Бандл читают только браузерные проверки: смоки и golden-сцены грузят
|
||
* дерево `demo/srv/assets/`, и мутант обязан попасть в entry и lazy chunks,
|
||
* иначе
|
||
* guard проверяет чистый код. Юнит- и бэкенд-гварды бандл не открывают ни в
|
||
* каком виде (проверено по реестру и по исходникам тестов: dist/** читается
|
||
* только как git-чекаут, который в worktree и так есть). Rollup-сборка —
|
||
* самая дорогая часть прогона (255 мутантов × ~15-20 с), поэтому она
|
||
* выполняется только там, где её результат кто-то откроет.
|
||
*/
|
||
export function guardNeedsBundle(guard) {
|
||
// `bundle:sync` в гварде реестр больше не допускает (--check, #499), но
|
||
// распознавание остаётся: чужой или старый гвард со сборкой всё равно
|
||
// браузерный, и бандл ему нужен.
|
||
return guard.includes('demo/') || guard.includes('bundle:sync');
|
||
}
|
||
|
||
/**
|
||
* Тёплый старт компиляции мутанта (#332): скопировать `test-build/` вместе с
|
||
* `.tsbuildinfo` из основного дерева. Мутант меняет один-два файла, и
|
||
* инкрементальный tsc пересобирает только их дельту вместо всего проекта;
|
||
* `.tsbuildinfo` сверяет файлы по хэшу содержимого, поэтому свежие mtime
|
||
* worktree его не сбивают, а мутированный файл гарантированно пересобирается.
|
||
* Отсутствие каталога в основном дереве — не ошибка: холодная сборка просто
|
||
* идёт прежним полным путём.
|
||
*/
|
||
function seedTestBuild(dir) {
|
||
const warm = join(repoRoot, 'test-build');
|
||
if (!existsSync(warm)) return;
|
||
cpSync(warm, join(dir, 'test-build'), { recursive: true });
|
||
}
|
||
|
||
/**
|
||
* Собрать `test-build/` из мутированного src в каталоге мутанта.
|
||
*
|
||
* Код выхода `tsc` игнорируется сознательно, по той же причине, что и в
|
||
* `buildBundle`: мутант воспроизводит поломку, а не образцовый код, и имеет
|
||
* право быть нестрогим по типам. Доказательством служит появление каталога —
|
||
* если его нет, падаем громко, а не отдаём тесту пустоту.
|
||
*/
|
||
function buildTestBuild(dir) {
|
||
seedTestBuild(dir);
|
||
sh('npx tsc -p tsconfig.test.json', dir);
|
||
const fixed = sh('node scripts/fix-test-build.mjs', dir);
|
||
if (!existsSync(join(dir, 'test-build'))) {
|
||
throw new Error(`test-build не собрался в мутанте:\n${(fixed.stderr || fixed.stdout).slice(-2000)}`);
|
||
}
|
||
}
|
||
|
||
function buildBundle(dir) {
|
||
// Только rollup, без tsc --noEmit: мутант имеет право быть нестрогим по
|
||
// типам — он воспроизводит поломку, а не образцовый код.
|
||
const built = sh('npx rollup -c', dir);
|
||
if (built.status !== 0) {
|
||
throw new Error(`сборка мутанта упала:\n${(built.stderr || built.stdout).slice(-2000)}`);
|
||
}
|
||
const synced = sh('node scripts/bundle-sync.mjs', dir);
|
||
if (synced.status !== 0) {
|
||
throw new Error(`дерево бандла мутанта не синхронизировалось:\n${(synced.stderr || synced.stdout).slice(-2000)}`);
|
||
}
|
||
}
|
||
|
||
function runMutant(mutant) {
|
||
const dir = makeWorktree();
|
||
try {
|
||
applyPatches(dir, mutant.patches);
|
||
if (guardNeedsBundle(mutant.guard)) buildBundle(dir);
|
||
if (guardNeedsTestBuild(mutant.guard)) buildTestBuild(dir);
|
||
const guard = sh(mutant.guard, dir);
|
||
if (guard.status === 0) {
|
||
console.log(`FAIL ${mutant.id}: тест остался зелёным на сломанном коде`);
|
||
console.log(` guard: ${mutant.guard}`);
|
||
console.log(` ${mutant.because}`);
|
||
return false;
|
||
}
|
||
console.log(`ok ${mutant.id}: тест покраснел, как обязан`);
|
||
return true;
|
||
} finally {
|
||
dropWorktree(dir);
|
||
}
|
||
}
|
||
|
||
// Чистый прогон каждого guard ровно один раз: тест, красный и без мутанта,
|
||
// «ловит» поломку тривиально и не доказывает ничего.
|
||
function runCleanGuards(mutants) {
|
||
const guards = [...new Set(mutants.map((m) => m.guard))];
|
||
const dir = makeWorktree();
|
||
try {
|
||
if (guards.some(guardNeedsBundle)) buildBundle(dir);
|
||
// Один worktree на все чистые гварды — значит и компиляция одна.
|
||
if (guards.some(guardNeedsTestBuild)) buildTestBuild(dir);
|
||
for (const guard of guards) {
|
||
const result = sh(guard, dir);
|
||
if (result.status !== 0) {
|
||
console.log(`FAIL чистый прогон: ${guard} красный без мутанта`);
|
||
console.log((result.stderr || result.stdout).slice(-1500));
|
||
return false;
|
||
}
|
||
console.log(`ok чистый прогон: ${guard}`);
|
||
}
|
||
return true;
|
||
} finally {
|
||
dropWorktree(dir);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Мутанты, чьи патч-файлы задеты диффом (#332). Дифф-режим — для локальной
|
||
* проверки и ревью-циклов; полный набор идёт ночным расписанием (#513),
|
||
* поэтому пустая выборка — честный успех с явным сообщением, а не ошибка.
|
||
*/
|
||
/**
|
||
* Файлы, на которые ссылается команда гарда (#475).
|
||
*
|
||
* Без парсинга команды: берутся токены с суффиксом `.mjs`, `.test.mjs` или
|
||
* `.py`, которые существуют в репозитории. Флаги (`--test-name-pattern=…`),
|
||
* шаблоны и произвольные слова файлами не считаются. Фикстуры гардов
|
||
* (`test/fixtures/*`, `tests_backend/fixtures/*`) по команде вывести нельзя —
|
||
* это граница: их дрейф остаётся полному прогону.
|
||
*/
|
||
export function guardFiles(guard, exists = (file) => existsSync(join(repoRoot, file))) {
|
||
const files = new Set();
|
||
for (const token of String(guard || '').split(/\s+/)) {
|
||
const bare = token.replace(/^["']|["']$/g, '');
|
||
// Путь, а не обрывок шаблона: только [A-Za-z0-9_./-], без кавычек,
|
||
// «|» и флагов. `--test-name-pattern="a|b.mjs"` даёт токен `b.mjs"` —
|
||
// он не файл, даже если бы такой существовал.
|
||
if (!/^[\w./-]+\.(mjs|py)$/.test(bare) || bare.startsWith('-')) continue;
|
||
if (exists(bare)) files.add(bare);
|
||
}
|
||
return [...files];
|
||
}
|
||
|
||
/**
|
||
* Обёртки гардов объявляют, что запускают (#492 §6.1): `export const
|
||
* GUARD_INPUTS = [...]` читается статически, без исполнения — обёртка при
|
||
* импорте сразу бежит и падает на usage. Файл гарда без объявления не
|
||
* считается обёрткой: его входы — только импорты и пути (§6.2).
|
||
*/
|
||
const GUARD_INPUTS_RE = /export const GUARD_INPUTS = \[([^\]]*)\]/;
|
||
export function wrapperInputs(file, read = (f) => (existsSync(join(repoRoot, f)) ? readFileSync(join(repoRoot, f), 'utf8') : '')) {
|
||
if (!/^scripts\/[\w-]+-guard\.mjs$/.test(file)) return [];
|
||
const m = GUARD_INPUTS_RE.exec(String(read(file)));
|
||
if (!m) return [];
|
||
return [...m[1].matchAll(/['"]([^'"]+)['"]/g)].map((x) => x[1]);
|
||
}
|
||
|
||
/** Сторона патча остаётся точечной (#492 §6.4): замыкание в `src/**` не идёт. */
|
||
const GUARD_CLOSURE_STOP = (file) => file.startsWith('src/') || file.startsWith('custom_components/houseplan/frontend/');
|
||
|
||
let trackedCache = null;
|
||
const tracked = () => (trackedCache ??= trackedFiles(repoRoot));
|
||
|
||
/**
|
||
* Все входы гарда (#492 §6): файлы из строки команды ∪ объявленные входы
|
||
* обёрток ∪ транзитивное замыкание по импортам и путям — смок тянет
|
||
* `serve.mjs` и фикстуры, pytest-модуль — `conftest.py`, обёртка — свои
|
||
* тесты. `src/**` исключён: это сторона патча, не гарда.
|
||
*/
|
||
export function guardInputs(guard, {
|
||
exists = (file) => existsSync(join(repoRoot, file)),
|
||
read,
|
||
files = tracked(),
|
||
} = {}) {
|
||
const named = guardFiles(guard, exists);
|
||
// Объявленное умолчание обёртки действует, когда гард не назвал файл сам:
|
||
// третий аргумент backend-test-guard уже стоит в строке и отбирается оттуда.
|
||
const isWrapper = (file) => /^scripts\/[\w-]+-guard\.mjs$/.test(file);
|
||
const wrappers = named.filter(isWrapper);
|
||
const explicit = named.some((file) => !isWrapper(file));
|
||
const declared = explicit ? [] : wrappers.flatMap((file) => wrapperInputs(file, read));
|
||
// Сама обёртка — вход (её текст в отпечатке), но её ссылки не читаются:
|
||
// умолчание уже учтено выше, а перечитывать его из текста значило бы
|
||
// отменять явный аргумент.
|
||
const entries = [...new Set([...named, ...declared])].filter(exists);
|
||
const reached = closure(repoRoot, entries.filter((file) => !wrappers.includes(file)),
|
||
{ tracked: files, read, stopAt: GUARD_CLOSURE_STOP });
|
||
return [...new Set([...entries, ...reached])]
|
||
.filter((file) => !GUARD_CLOSURE_STOP(file))
|
||
.sort();
|
||
}
|
||
|
||
/**
|
||
* Радиус области якоря (#518): сколько строк вокруг патча считается «его
|
||
* кодом». Хост-файлы карты — тринадцать тысяч строк, и правка в одном их
|
||
* конце перегоняла свидетелей из другого: на #500 двенадцать изменённых строк
|
||
* `houseplan-editor-runtime.ts` тянули 53 мутанта из 75. Сорок строк — то
|
||
* расстояние, на котором правка ещё почти всегда трогает тот же код; дальше
|
||
* начинается чужой, и его перебирает ночной полный гейт (#513).
|
||
*/
|
||
export const ANCHOR_RADIUS_LINES = 40;
|
||
|
||
/**
|
||
* Строки области якоря, 1-based включительно, или `null` — когда `find`
|
||
* встречается в файле не ровно один раз. `null` значит «судить по файлу
|
||
* целиком»: реестр, отставший от кода, обязан отвечать консервативно, а не
|
||
* сужать проверку (эту же однократность требуют `--check` и `applyPatches`).
|
||
*/
|
||
export function anchorSpan(source, find, radius = ANCHOR_RADIUS_LINES) {
|
||
const text = String(source ?? '');
|
||
if (!find || text.split(find).length - 1 !== 1) return null;
|
||
const start = text.indexOf(find);
|
||
const before = text.slice(0, start).split('\n').length; // 1-based строка начала
|
||
const inside = String(find).split('\n').length - 1;
|
||
const lines = text.split('\n').length;
|
||
return {
|
||
from: Math.max(1, before - radius),
|
||
to: Math.min(lines, before + inside + radius),
|
||
};
|
||
}
|
||
|
||
/** Текст области якоря (или весь файл, если якорь не однозначен). */
|
||
export function anchorRegion(source, find, radius = ANCHOR_RADIUS_LINES) {
|
||
const text = String(source ?? '');
|
||
const span = anchorSpan(text, find, radius);
|
||
if (!span) return text;
|
||
return text.split('\n').slice(span.from - 1, span.to).join('\n');
|
||
}
|
||
|
||
/**
|
||
* Изменённые области по файлам из `git diff --unified=0` — стороны ГОЛОВЫ
|
||
* (`+`), потому что якоря ищутся в рабочем дереве. Чистое удаление даёт
|
||
* нулевую длину `+c,0`: считаем задетыми строки вокруг стыка, иначе вырезанный
|
||
* кусок кода не задел бы никого.
|
||
*/
|
||
export function parseDiffRanges(diffText) {
|
||
const ranges = new Map();
|
||
let file = null;
|
||
for (const line of String(diffText ?? '').split('\n')) {
|
||
const head = /^\+\+\+ (?:b\/)?(.+)$/.exec(line);
|
||
if (head) { file = head[1] === '/dev/null' ? null : head[1]; continue; }
|
||
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
||
if (!hunk || !file) continue;
|
||
const from = Number(hunk[1]);
|
||
const count = hunk[2] === undefined ? 1 : Number(hunk[2]);
|
||
const list = ranges.get(file) ?? [];
|
||
list.push(count === 0 ? [from, from + 1] : [from, from + count - 1]);
|
||
ranges.set(file, list);
|
||
}
|
||
return ranges;
|
||
}
|
||
|
||
const spansOverlap = (span, [from, to]) => from <= span.to && to >= span.from;
|
||
|
||
/**
|
||
* Задел ли дифф область якоря патча (#518). `ranges` — карта из
|
||
* `parseDiffRanges`; её отсутствие означает прежний ответ по файлу целиком.
|
||
*/
|
||
export function patchTouched(patch, ranges, read) {
|
||
if (!ranges) return true;
|
||
const hunks = ranges.get(patch.file);
|
||
if (!hunks || !hunks.length) return true;
|
||
const span = anchorSpan(read(patch.file), patch.find);
|
||
if (!span) return true; // якорь не однозначен — судим по файлу
|
||
return hunks.some((hunk) => spansOverlap(span, hunk));
|
||
}
|
||
|
||
/**
|
||
* Отпечаток свидетеля (#481): содержимое файлов патча и гарда плюс само
|
||
* объявление мутанта. Строка версии продукта нормализуется, как в
|
||
* `visualFingerprint` (#245): релизный бамп трогает `houseplan-card.ts` и
|
||
* `houseplan-editor-runtime.ts`, по которым отбираются десятки мутантов, но
|
||
* ни одного свидетеля не меняет. Приближение то же, что у отбора по диффу:
|
||
* непрямые зависимости гарда не учитываются — полный прогон остаётся
|
||
* контрактом, журнал его не заменяет.
|
||
*/
|
||
export function witnessFingerprint(mutant, {
|
||
root = repoRoot,
|
||
read = (file) => (existsSync(join(root, file)) ? readFileSync(join(root, file), 'utf8') : ''),
|
||
exists = (file) => existsSync(join(root, file)),
|
||
normalize = withoutProductVersion(root),
|
||
} = {}) {
|
||
const hash = createHash('sha256');
|
||
hash.update(JSON.stringify({ id: mutant.id, guard: mutant.guard, patches: mutant.patches }));
|
||
hash.update('\0');
|
||
const text = (file) => String(read(file)).replace(/\r\n?/g, '\n');
|
||
// Сторона патча — только область якоря (#518); сторона гарда — файл целиком:
|
||
// у гарда якоря нет, он судит поведение и меняется весь.
|
||
const entries = [
|
||
...mutant.patches.map((patch, index) => [
|
||
`${patch.file}#якорь-${index}`,
|
||
() => anchorRegion(text(patch.file), patch.find),
|
||
]),
|
||
...guardInputs(mutant.guard, { exists, read }).map((file) => [file, () => text(file)]),
|
||
].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
||
for (const [key, valueOf] of entries) {
|
||
hash.update(key);
|
||
hash.update('\0');
|
||
hash.update(normalize(valueOf()));
|
||
hash.update('\0');
|
||
}
|
||
return hash.digest('hex');
|
||
}
|
||
|
||
export const LEDGER_SCHEMA = 1;
|
||
|
||
/** Журнал пойманных свидетелей: `{ schema, caught: { [id]: fingerprint } }`. */
|
||
export function readLedger(file) {
|
||
if (!file || !existsSync(file)) return { schema: LEDGER_SCHEMA, caught: {} };
|
||
try {
|
||
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
||
if (parsed?.schema !== LEDGER_SCHEMA || typeof parsed.caught !== 'object' || !parsed.caught) {
|
||
return { schema: LEDGER_SCHEMA, caught: {} };
|
||
}
|
||
return { schema: LEDGER_SCHEMA, caught: { ...parsed.caught } };
|
||
} catch {
|
||
// Битый журнал — не отказ гейта: он лишь сужает работу, и пустой журнал
|
||
// означает «гонять всё отобранное», то есть прежнее поведение.
|
||
return { schema: LEDGER_SCHEMA, caught: {} };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Записать пойманного свидетеля — сразу, а не в конце прогона: отменённый
|
||
* или упавший по таймауту шард обязан сохранить уже сделанное, иначе
|
||
* следующий пуш начинает с нуля (снежный ком #481).
|
||
*/
|
||
export function recordCaught(file, ledger, mutant, fingerprint) {
|
||
ledger.caught[mutant.id] = fingerprint;
|
||
mkdirSync(dirname(file), { recursive: true });
|
||
writeFileSync(file, `${JSON.stringify({ schema: LEDGER_SCHEMA, caught: ledger.caught }, null, 2)}\n`);
|
||
}
|
||
|
||
/**
|
||
* Мутанты, чей отпечаток совпадает с журналом, уже доказали, что ловятся на
|
||
* этих же входах — их пропуск ничего не ослабляет. Сравнивается ОТПЕЧАТОК,
|
||
* не факт присутствия id: запись от другого содержимого файлов не считается.
|
||
*/
|
||
export function splitByLedger(mutants, ledger, fingerprintOf = (m) => witnessFingerprint(m)) {
|
||
const run = [];
|
||
const skipped = [];
|
||
for (const mutant of mutants) {
|
||
const fingerprint = fingerprintOf(mutant);
|
||
if (ledger.caught[mutant.id] === fingerprint) skipped.push(mutant);
|
||
else run.push({ mutant, fingerprint });
|
||
}
|
||
return { run, skipped };
|
||
}
|
||
|
||
/**
|
||
* Мутанты, затронутые диффом (#332, расширено в #475).
|
||
*
|
||
* Два способа свидетелю сгнить: изменился файл, который он патчит, — либо
|
||
* изменился его гард (тест, смок, pytest-модуль), и тот перестал ходить по
|
||
* мутированной ветке. Прежде отбор видел только первый; так после #302/#309
|
||
* четыре мутанта пережили свои гарды и обнаружились лишь полным прогоном
|
||
* перед v1.72.0 (#466, #467).
|
||
*/
|
||
export function selectChangedMutants(mutants, changedFiles, exists, options = {}) {
|
||
const changed = new Set(changedFiles);
|
||
const inputsOf = options.guardInputs || ((guard) => guardInputs(guard, { exists, ...options }));
|
||
// #518: когда известны области диффа, файл патча отбирает свидетеля лишь
|
||
// тем, что задел его якорь. Без областей — прежний ответ по файлу.
|
||
const ranges = options.ranges || null;
|
||
const read = options.read || ((file) => (existsSync(join(repoRoot, file)) ? readFileSync(join(repoRoot, file), 'utf8') : ''));
|
||
return mutants.filter((m) => m.patches.some((patch) => changed.has(patch.file)
|
||
&& patchTouched(patch, ranges, read))
|
||
|| inputsOf(m.guard).some((file) => changed.has(file)));
|
||
}
|
||
|
||
/**
|
||
* Задевает ли правка package.json хоть один гвард (#496, run 2795).
|
||
*
|
||
* package.json — вход почти каждого гварда (`npm run …`, `npx …`), поэтому любое
|
||
* его касание отбирало ~195 мутантов из 590 и шард упирался в 30-минутный
|
||
* лимит job. Но гвард зависит только от того, что он вызывает: изменённый или
|
||
* удалённый существующий script, зависимости, engines/overrides. ДОБАВЛЕННЫЙ
|
||
* script никакой прежний гвард не вызывает — он не вход. Любое другое поле или
|
||
* непрочитанная база — считается задевающим: сторона ошибки — лишний прогон.
|
||
*/
|
||
export function packageJsonRelevance(baseText, headText) {
|
||
let base; let head;
|
||
try { base = JSON.parse(baseText); head = JSON.parse(headText); } catch { return { relevant: true, reason: 'package.json не разобран' }; }
|
||
const baseScripts = base.scripts || {}; const headScripts = head.scripts || {};
|
||
const changedScripts = Object.keys(baseScripts).filter((k) => headScripts[k] !== baseScripts[k]);
|
||
if (changedScripts.length) return { relevant: true, reason: `изменены/удалены scripts: ${changedScripts.join(', ')}` };
|
||
const rest = (pkg) => JSON.stringify({ ...pkg, scripts: undefined, description: undefined });
|
||
if (rest(base) !== rest(head)) return { relevant: true, reason: 'изменены поля вне scripts (зависимости, engines, …)' };
|
||
const added = Object.keys(headScripts).filter((k) => !(k in baseScripts));
|
||
return { relevant: false, reason: `только добавлены scripts: ${added.join(', ') || '—'}` };
|
||
}
|
||
|
||
/**
|
||
* Определения реестра, добавленные или изменённые относительно базы (#492
|
||
* §6.4): реестр базы читается через `git show` во временный модуль рядом с
|
||
* этим файлом (относительные импорты обязаны разрешаться) и импортируется —
|
||
* реестр данные, побочных эффектов при импорте нет (закреплено тестом).
|
||
* Удалённые id возвращаются отдельно: гонять их нечем, но сказать стоит.
|
||
*/
|
||
export function registryDelta(current, base) {
|
||
const shape = (m) => JSON.stringify({ guard: m.guard, patches: m.patches, because: m.because });
|
||
const before = new Map(base.map((m) => [m.id, shape(m)]));
|
||
const changed = current.filter((m) => before.get(m.id) !== shape(m)).map((m) => m.id);
|
||
const removed = base.filter((m) => !current.some((c) => c.id === m.id)).map((m) => m.id);
|
||
return { changed, removed };
|
||
}
|
||
|
||
/**
|
||
* Отбор для диффа (#332, #475, #492): по файлам патчей и входам гардов плюс —
|
||
* когда дифф трогает сам реестр — по добавленным/изменённым определениям
|
||
* относительно реестра базы (`base`, null — база не прочитана).
|
||
*/
|
||
export function selectForDiff(mutants, files, base, options = {}) {
|
||
const byFiles = selectChangedMutants(mutants, files, options.exists, options);
|
||
let byRegistry = [];
|
||
let removed = [];
|
||
if (files.includes('scripts/mutation-gate.mjs') && base) {
|
||
const delta = registryDelta(mutants, base);
|
||
byRegistry = mutants.filter((m) => delta.changed.includes(m.id));
|
||
removed = delta.removed;
|
||
}
|
||
const ids = new Set([...byFiles, ...byRegistry].map((m) => m.id));
|
||
return { selected: mutants.filter((m) => ids.has(m.id)), byFiles, byRegistry, removed };
|
||
}
|
||
|
||
export async function baseRegistry(baseRef) {
|
||
const shown = spawnSync('git', ['-C', repoRoot, 'show', `${baseRef}:scripts/mutation-gate.mjs`], { encoding: 'utf8' });
|
||
if (shown.status !== 0) return null;
|
||
const temp = join(repoRoot, 'scripts', `.mutation-gate.base-${process.pid}.mjs`);
|
||
writeFileSync(temp, shown.stdout);
|
||
try {
|
||
const mod = await import(`${pathToFileURL(temp).href}?t=${Date.now()}`);
|
||
return mod.MUTANTS;
|
||
} finally {
|
||
rmSync(temp, { force: true });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Детерминированный шард `index/total` (#332): реестр сортируется по id и
|
||
* режется чересполосно, чтобы дорогие смок-мутанты (соседи по алфавиту)
|
||
* не скапливались в одном шарде. Объединение шардов равно реестру,
|
||
* пересечений нет — закреплено юнитом.
|
||
*/
|
||
export function shardMutants(mutants, index, total) {
|
||
const ordered = [...mutants].sort((a, b) => a.id.localeCompare(b.id));
|
||
return ordered.filter((_, position) => position % total === index - 1);
|
||
}
|
||
|
||
async function main(argv) {
|
||
const idArg = argv.find((a) => a.startsWith('--id='))?.slice(5);
|
||
let selected = idArg ? MUTANTS.filter((m) => m.id === idArg) : MUTANTS;
|
||
if (idArg && !selected.length) {
|
||
console.error(`мутант «${idArg}» не объявлен; --list покажет реестр`);
|
||
return 2;
|
||
}
|
||
|
||
const changedArg = argv.find((a) => a === '--changed' || a.startsWith('--changed='));
|
||
const ledgerArg = argv.find((a) => a.startsWith('--ledger='))?.slice(9);
|
||
if (ledgerArg && !changedArg) {
|
||
// Полный прогон журнал не читает — он его пишет по расписанию целиком;
|
||
// читать журнал в полном прогоне значило бы никогда не перепроверять.
|
||
console.error('--ledger работает только вместе с --changed: полный прогон журнал не читает');
|
||
return 2;
|
||
}
|
||
if (changedArg) {
|
||
const range = changedArg.includes('=') ? changedArg.split('=')[1] : 'origin/dev..HEAD';
|
||
const diff = spawnSync('git', ['-C', repoRoot, 'diff', '--name-only', range],
|
||
{ encoding: 'utf8' });
|
||
if (diff.status !== 0) {
|
||
console.error(`git diff ${range} не удался:\n${diff.stderr}`);
|
||
return 2;
|
||
}
|
||
let files = diff.stdout.split('\n').filter(Boolean);
|
||
const before = selected.length;
|
||
// #496: package.json отбирает ~все гварды; добавленный script — не вход.
|
||
if (files.includes('package.json') && range.includes('..')) {
|
||
const [baseRef, headRef] = range.split('..');
|
||
const show = (ref) => spawnSync('git', ['-C', repoRoot, 'show', `${ref}:package.json`], { encoding: 'utf8' });
|
||
const baseShown = show(baseRef); const headShown = show(headRef || 'HEAD');
|
||
const relevance = (baseShown.status === 0 && headShown.status === 0)
|
||
? packageJsonRelevance(baseShown.stdout, headShown.stdout)
|
||
: { relevant: true, reason: 'package.json базы или головы не прочитан' };
|
||
console.log(`package.json в диффе: ${relevance.relevant ? 'задевает гварды' : 'гварды не задевает'} — ${relevance.reason}`);
|
||
if (!relevance.relevant) files = files.filter((f) => f !== 'package.json');
|
||
}
|
||
// #492 §6.4: правка реестра отбирает добавленные и изменённые определения
|
||
// явно — новый свидетель не обязан трогать чужие patch/guard-файлы.
|
||
let base = null;
|
||
if (files.includes('scripts/mutation-gate.mjs')) {
|
||
const baseRef = range.includes('..') ? range.split('..')[0] : range;
|
||
base = await baseRegistry(baseRef);
|
||
if (!base) console.log(`реестр базы ${baseRef} не прочитан — отбор по определениям пропущен`);
|
||
}
|
||
// #518: области диффа сужают сторону патча до окрестности якоря. Не
|
||
// прочитались — отбор остаётся файловым, то есть прежним и более широким.
|
||
const hunks = spawnSync('git', ['-C', repoRoot, 'diff', '--unified=0', '--no-color', range],
|
||
{ encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
|
||
const ranges = hunks.status === 0 && !hunks.error ? parseDiffRanges(hunks.stdout) : null;
|
||
if (!ranges) console.log('области диффа не прочитаны — отбор по файлам целиком');
|
||
const wide = selectForDiff(selected, files, base);
|
||
const picked = selectForDiff(selected, files, base, { ranges });
|
||
if (picked.removed.length) console.log(`удалены из реестра: ${picked.removed.join(', ')}`);
|
||
selected = picked.selected;
|
||
console.log(`дифф-режим ${range}: файлов в диффе ${files.length}, `
|
||
+ `мутантов затронуто ${selected.length} из ${before} (по файлам ${picked.byFiles.length}, по определениям ${picked.byRegistry.length})`);
|
||
if (ranges && wide.selected.length !== selected.length) {
|
||
console.log(`области якорей (радиус ${ANCHOR_RADIUS_LINES} строк, #518): `
|
||
+ `${wide.selected.length} → ${selected.length}`);
|
||
}
|
||
if (!selected.length) {
|
||
console.log('дифф не задевает ни одной области якоря — гонять нечего; '
|
||
+ 'полный реестр идёт ночным расписанием (#513)');
|
||
if (argv.includes('--plan-only')) console.log('plan=0');
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
const shardArg = argv.find((a) => a.startsWith('--shard='))?.slice(8);
|
||
if (shardArg) {
|
||
const match = /^([1-9]\d*)\/([1-9]\d*)$/.exec(shardArg);
|
||
if (!match || Number(match[1]) > Number(match[2])) {
|
||
console.error(`--shard ожидает i/n с 1 <= i <= n, получено «${shardArg}»`);
|
||
return 2;
|
||
}
|
||
const before = selected.length;
|
||
selected = shardMutants(selected, Number(match[1]), Number(match[2]));
|
||
console.log(`шард ${shardArg}: ${selected.length} из ${before} мутантов`);
|
||
}
|
||
|
||
if (argv.includes('--list')) {
|
||
for (const m of MUTANTS) console.log(`${m.id}\n guard: ${m.guard}\n ${m.because}\n`);
|
||
return 0;
|
||
}
|
||
|
||
if (argv.includes('--check')) {
|
||
let stale = 0;
|
||
for (const m of selected) {
|
||
try {
|
||
// #499: бандл мутанта собирает раннер (`buildBundle`, только rollup +
|
||
// sync). Гвард с собственным `npm run bundle:sync` собирал бы его второй
|
||
// раз — плюс `tsc --noEmit`, который на нестрогом мутанте падает сам и
|
||
// красит гвард ещё до теста: «мутант пойман» без единого запуска смока.
|
||
if (/bundle:sync|bundle-sync\.mjs|rollup -c/.test(m.guard)) {
|
||
throw new Error('гвард сам собирает бандл — сборку делает раннер (#499)');
|
||
}
|
||
for (const patch of m.patches) {
|
||
const source = readFileSync(join(repoRoot, patch.file), 'utf8');
|
||
const hits = source.split(patch.find).length - 1;
|
||
if (hits !== 1) throw new Error(`якорь найден ${hits} раз(а)`);
|
||
}
|
||
console.log(`ok ${m.id}`);
|
||
} catch (error) {
|
||
console.log(`FAIL ${m.id}: ${error.message}`);
|
||
stale++;
|
||
}
|
||
}
|
||
return stale ? 2 : 0;
|
||
}
|
||
|
||
if (argv.includes('--build-only')) {
|
||
for (const m of selected) {
|
||
const dir = makeWorktree();
|
||
try {
|
||
applyPatches(dir, m.patches);
|
||
buildBundle(dir);
|
||
console.log(`ok ${m.id}: патч лёг, бандл собрался`);
|
||
} catch (error) {
|
||
console.log(`FAIL ${m.id}: ${error.message}`);
|
||
return 2;
|
||
} finally {
|
||
dropWorktree(dir);
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// Журнал (#481): отобранные по диффу мутанты, чьи входы не менялись с
|
||
// последнего пойманного прогона, не гоняются повторно.
|
||
let plan = selected.map((mutant) => ({ mutant, fingerprint: null }));
|
||
let ledger = null;
|
||
if (ledgerArg) {
|
||
ledger = readLedger(ledgerArg);
|
||
const split = splitByLedger(selected, ledger);
|
||
console.log(`журнал ${ledgerArg}: по журналу пропущено ${split.skipped.length} `
|
||
+ `(отпечатки совпали), к прогону ${split.run.length}`);
|
||
plan = split.run;
|
||
if (!plan.length) {
|
||
console.log('все отобранные свидетели уже пойманы на этих же входах — гонять нечего');
|
||
if (argv.includes('--plan-only')) console.log('plan=0');
|
||
return 0;
|
||
}
|
||
}
|
||
const toRun = plan.map((entry) => entry.mutant);
|
||
// #518: `--plan-only` считает план и выходит — job мутантов спрашивает его
|
||
// ДО установки окружения (npm ci, python, Chromium ≈ 3 минуты на шард).
|
||
if (argv.includes('--plan-only')) {
|
||
console.log(`plan=${toRun.length}`);
|
||
return 0;
|
||
}
|
||
if (!runCleanGuards(toRun)) return 2;
|
||
let caught = 0;
|
||
for (const entry of plan) {
|
||
if (!runMutant(entry.mutant)) continue;
|
||
caught++;
|
||
if (ledger) recordCaught(ledgerArg, ledger, entry.mutant, entry.fingerprint);
|
||
}
|
||
console.log(`\nпоймано ${caught} из ${toRun.length}`);
|
||
return caught === toRun.length ? 0 : 1;
|
||
}
|
||
|
||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||
main(process.argv.slice(2)).then((code) => process.exit(code), (err) => {
|
||
console.error(err);
|
||
process.exit(2);
|
||
});
|
||
}
|