test(mutation): сократить browser guards реестра (#659)

Issue: #659
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-27 17:08:06 +03:00
parent b9aa2b54b1
commit 718afca221
12 changed files with 810 additions and 56 deletions
+10
View File
@@ -8,6 +8,10 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { METRIC_NAMES, collectMetrics, readBaseline } from './monolith-metrics.mjs';
import { MUTANTS } from './mutation-registry.mjs';
import {
BROWSER_GUARD_LIMIT, browserGuardPolicy, readDocumentedBrowserGuards,
} from './mutation-browser-policy.mjs';
import { isMainModule } from './spawn-portable.mjs';
const count = (root, dir, match, re) =>
@@ -39,6 +43,12 @@ if (isMainModule(import.meta.url)) {
const w = Math.max(...rows.map(([n]) => n.length));
for (const [name, n] of rows) console.log(`${name.padEnd(w)} ${n}`);
const browser = browserGuardPolicy(MUTANTS, readDocumentedBrowserGuards(process.cwd()));
console.log(`\nmutation browser guards ${browser.count}/${BROWSER_GUARD_LIMIT}`);
if (browser.missingReasons.length || browser.staleReasons.length) {
console.log(`mutation browser inventory drift +${browser.missingReasons.length} / -${browser.staleReasons.length}`);
}
// #624: связность монолита — те же шесть чисел и тот же модуль, что у гейта
// `npm run lint:unused` («одно число — один источник»); рядом — база, чтобы
// движение было видно без git blame. bundleBytes есть только после сборки.
+30
View File
@@ -0,0 +1,30 @@
// Browser mutation witnesses are the expensive part of the registry. This
// module keeps the cap and the reviewed inventory independent from the CLI so
// inventory, --check and unit tests ask the same question (#659).
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
export const BROWSER_GUARD_LIMIT = 200;
export const BROWSER_GUARD_INVENTORY = 'docs/testing-notes/mutation-browser-guards.md';
export const isBrowserGuard = (guard) => guard.includes('demo/') || guard.includes('bundle:sync');
export function documentedBrowserGuards(markdown) {
return new Set([...markdown.matchAll(/^- `([^`]+)`$/gm)].map((match) => match[1]));
}
export function readDocumentedBrowserGuards(root = process.cwd()) {
return documentedBrowserGuards(readFileSync(join(root, BROWSER_GUARD_INVENTORY), 'utf8'));
}
export function browserGuardPolicy(mutants, documented) {
const browser = mutants.filter((mutant) => isBrowserGuard(mutant.guard));
const current = new Set(browser.map((mutant) => mutant.id));
return {
browser,
count: browser.length,
overLimit: browser.length > BROWSER_GUARD_LIMIT,
missingReasons: browser.filter((mutant) => !documented.has(mutant.id)),
staleReasons: [...documented].filter((id) => !current.has(id)),
};
}
+58
View File
@@ -0,0 +1,58 @@
// One clean frontend bundle can serve every browser mutant whose patch does
// not touch a bundle input. Keep this policy outside the execution lifecycle:
// the runner applies it, while tests can judge it without making worktrees.
import { cpSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { fingerprintCorpus } from './source-fingerprint.mjs';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
const BUNDLE_CORPUS = new Set(fingerprintCorpus(repoRoot));
const BUNDLE_TREES = ['dist', 'demo/srv/assets'];
export function guardNeedsBundle(guard) {
return guard.includes('demo/') || guard.includes('bundle:sync');
}
export function mutantPatchesNeedBundle(patches, corpus = BUNDLE_CORPUS) {
const bundled = corpus instanceof Set ? corpus : new Set(corpus);
return patches.some((patch) => bundled.has(patch.file.replaceAll('\\', '/')));
}
export function mutantBundleStrategy(mutant, { seedAvailable = false, corpus } = {}) {
if (!guardNeedsBundle(mutant.guard)) return 'none';
if (seedAvailable && !mutantPatchesNeedBundle(mutant.patches, corpus)) return 'seed';
return 'build';
}
export const planNeedsBundleSeed = (mutants) => mutants.some((mutant) => (
guardNeedsBundle(mutant.guard) && !mutantPatchesNeedBundle(mutant.patches)
));
export const makeBundleSeed = () => mkdtempSync(join(tmpdir(), 'hp-mutant-bundle-'));
export function dropBundleSeed(dir) {
if (dir) rmSync(dir, { recursive: true, force: true });
}
export function captureBundleSeed(root, seed) {
for (const name of BUNDLE_TREES) {
const from = join(root, name);
if (!existsSync(from)) throw new Error(`bundle seed source is missing: ${name}`);
const to = join(seed, name);
rmSync(to, { recursive: true, force: true });
cpSync(from, to, { recursive: true });
}
}
export function restoreBundleSeed(seed, root) {
for (const name of BUNDLE_TREES) {
const from = join(seed, name);
if (!existsSync(from)) throw new Error(`shared bundle seed is missing: ${name}`);
const to = join(root, name);
rmSync(to, { recursive: true, force: true });
cpSync(from, to, { recursive: true });
}
}
+17 -10
View File
@@ -12,6 +12,14 @@ import { fileURLToPath } from 'node:url';
import {
MUTATION_OUTCOME, MUTATION_PROOF, runGuardPhases, runMutationLifecycle, setupFailureOwner,
} from './mutation-guard-outcome.mjs';
import {
captureBundleSeed, guardNeedsBundle, mutantBundleStrategy, restoreBundleSeed,
} from './mutation-bundle-cache.mjs';
export {
dropBundleSeed, guardNeedsBundle, makeBundleSeed, mutantBundleStrategy,
mutantPatchesNeedBundle, planNeedsBundleSeed,
} from './mutation-bundle-cache.mjs';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
@@ -88,12 +96,6 @@ export function guardNeedsTestBuild(guard) {
* самая дорогая часть прогона (255 мутантов × ~15-20 с), поэтому она
* выполняется только там, где её результат кто-то откроет.
*/
export function guardNeedsBundle(guard) {
// `bundle:sync` в гварде реестр больше не допускает (--check, #499), но
// распознавание остаётся: чужой или старый гвард со сборкой всё равно
// браузерный, и бандл ему нужен.
return guard.includes('demo/') || guard.includes('bundle:sync');
}
/**
* Тёплый старт компиляции мутанта (#332): скопировать `test-build/` вместе с
@@ -168,14 +170,16 @@ function printMutantOutcome(mutant, outcome) {
if (outcome.detail) console.log(` ${outcome.detail}`);
}
export function runMutant(mutant, { ref = 'HEAD' } = {}) {
export function runMutant(mutant, { ref = 'HEAD', bundleSeed = null } = {}) {
let dir;
try {
dir = makeWorktree(ref);
const outcome = runMutationLifecycle({
apply: () => applyPatches(dir, mutant.patches),
prepare: () => {
if (guardNeedsBundle(mutant.guard)) buildBundle(dir);
const bundle = mutantBundleStrategy(mutant, { seedAvailable: Boolean(bundleSeed) });
if (bundle === 'seed') restoreBundleSeed(bundleSeed, dir);
else if (bundle === 'build') buildBundle(dir);
if (guardNeedsTestBuild(mutant.guard)) buildTestBuild(dir);
},
guard: mutant.guard,
@@ -199,12 +203,15 @@ export function runMutant(mutant, { ref = 'HEAD' } = {}) {
// Чистый прогон каждого guard ровно один раз: тест, красный и без мутанта,
// «ловит» поломку тривиально и не доказывает ничего.
export function runCleanGuards(mutants) {
export function runCleanGuards(mutants, { bundleSeed = null } = {}) {
const guards = [...new Set(mutants.map((m) => m.guard))];
const dir = makeWorktree();
try {
try {
if (guards.some(guardNeedsBundle)) buildBundle(dir);
if (guards.some(guardNeedsBundle)) {
buildBundle(dir);
if (bundleSeed) captureBundleSeed(dir, bundleSeed);
}
// Один worktree на все чистые гварды — значит и компиляция одна.
if (guards.some(guardNeedsTestBuild)) buildTestBuild(dir);
} catch (error) {
+15 -43
View File
@@ -10,9 +10,11 @@ import { fileURLToPath } from 'node:url';
import { MUTANTS } from './mutation-registry.mjs';
import {
applyPatches, buildBundle, dropWorktree, guardNeedsBundle, guardNeedsTestBuild,
makeWorktree, runCleanGuards, runMutant,
applyPatches, buildBundle, dropBundleSeed, dropWorktree, guardNeedsBundle, guardNeedsTestBuild,
makeBundleSeed, makeWorktree, mutantBundleStrategy, mutantPatchesNeedBundle,
planNeedsBundleSeed, runCleanGuards, runMutant,
} from './mutation-execution.mjs';
import { checkMutationRegistry } from './mutation-registry-check.mjs';
import {
ANCHOR_RADIUS_LINES, MUTATION_REGISTRY_FILES, anchorRegion, anchorSpan,
baseRegistry, createGuardInputResolver, guardFiles, guardInputs,
@@ -24,9 +26,7 @@ import {
} from './mutation-evidence.mjs';
import { attributeSetupFailure } from './mutation-attribution.mjs';
import { guardEnvironment, planEnvironment, planEnvironmentLines } from './mutation-environment.mjs';
import {
MUTATION_OUTCOME, isProofOutcome, staticTestSelectionProblems,
} from './mutation-guard-outcome.mjs';
import { MUTATION_OUTCOME, isProofOutcome } from './mutation-guard-outcome.mjs';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
@@ -36,6 +36,7 @@ export {
ANCHOR_RADIUS_LINES, LEDGER_SCHEMA, MUTANTS, MUTATION_REGISTRY_FILES,
anchorRegion, anchorSpan, applyPatches, baseRegistry, createGuardInputResolver,
guardFiles, guardInputs, guardNeedsBundle, guardNeedsTestBuild,
mutantBundleStrategy, mutantPatchesNeedBundle,
packageJsonRelevance, parseDiffRanges, patchTouched, readLedger, recordCaught,
registryDelta, selectChangedMutants, selectForDiff, shardMutants,
splitByLedger, witnessFingerprint, wrapperInputs,
@@ -152,43 +153,9 @@ export async function main(argv) {
}
if (argv.includes('--check')) {
let stale = 0;
let warned = 0;
const readTest = (file) => {
const path = join(repoRoot, file);
return existsSync(path) ? readFileSync(path, 'utf8') : null;
};
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} раз(а)`);
}
// #650: a name filter that matches nothing makes the witness vacuous.
const selection = staticTestSelectionProblems(m.guard, readTest);
const error = selection.find((problem) => problem.level === 'error');
if (error) throw new Error(error.text);
for (const problem of selection) {
console.log(`WARN ${m.id}: ${problem.text}`);
warned++;
}
console.log(`ok ${m.id}`);
} catch (error) {
console.log(`FAIL ${m.id}: ${error.message}`);
stale++;
}
}
if (warned) console.log(`предупреждений о шаблонах имён: ${warned}`);
const result = checkMutationRegistry(selected, { allMutants: MUTANTS, root: repoRoot });
reportPlanMetrics();
return stale ? 2 : 0;
return result;
}
if (argv.includes('--build-only')) {
@@ -241,7 +208,9 @@ export async function main(argv) {
return 0;
}
reportPlanMetrics();
if (!runCleanGuards(toRun)) return 2;
const bundleSeed = planNeedsBundleSeed(toRun) ? makeBundleSeed() : null;
try {
if (!runCleanGuards(toRun, { bundleSeed })) return 2;
let caught = 0;
let unverifiable = false;
// #568: свидетель, который не готовится к прогону, ломает гейт той задачи,
@@ -252,7 +221,7 @@ export async function main(argv) {
// прогона одного и того же мутанта, а не код с ожиданием.
const preExisting = [];
for (const entry of plan) {
const outcome = runMutant(entry.mutant);
const outcome = runMutant(entry.mutant, { bundleSeed });
if (!isProofOutcome(outcome)) {
if (outcome.kind === MUTATION_OUTCOME.SETUP) {
const verdict = await attributeSetupFailure(entry.mutant, outcome, rangeBase);
@@ -285,6 +254,9 @@ export async function main(argv) {
}
if (unverifiable) return 2;
return caught === toRun.length - preExisting.length ? 0 : 1;
} finally {
dropBundleSeed(bundleSeed);
}
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
+63
View File
@@ -0,0 +1,63 @@
// Cheap structural validation for mutation declarations. Kept out of the CLI
// so adding registry policy does not grow the execution coordinator (#558).
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import {
BROWSER_GUARD_LIMIT, browserGuardPolicy, readDocumentedBrowserGuards,
} from './mutation-browser-policy.mjs';
import { staticTestSelectionProblems } from './mutation-guard-outcome.mjs';
export function checkMutationRegistry(selected, { allMutants, root, log = console.log }) {
let stale = 0;
let warned = 0;
const readTest = (file) => {
const path = join(root, file);
return existsSync(path) ? readFileSync(path, 'utf8') : null;
};
for (const mutant of selected) {
try {
if (/bundle:sync|bundle-sync\.mjs|rollup -c/.test(mutant.guard)) {
throw new Error('гвард сам собирает бандл — сборку делает раннер (#499)');
}
for (const patch of mutant.patches) {
const source = readFileSync(join(root, patch.file), 'utf8');
const hits = source.split(patch.find).length - 1;
if (hits !== 1) throw new Error(`якорь найден ${hits} раз(а)`);
}
const selection = staticTestSelectionProblems(mutant.guard, readTest);
const error = selection.find((problem) => problem.level === 'error');
if (error) throw new Error(error.text);
for (const problem of selection) {
log(`WARN ${mutant.id}: ${problem.text}`);
warned++;
}
log(`ok ${mutant.id}`);
} catch (error) {
log(`FAIL ${mutant.id}: ${error.message}`);
stale++;
}
}
try {
const policy = browserGuardPolicy(allMutants, readDocumentedBrowserGuards(root));
log(`browser guards: ${policy.count}/${BROWSER_GUARD_LIMIT}`);
if (policy.overLimit) {
log(`FAIL browser guards: лимит ${BROWSER_GUARD_LIMIT} превышен`);
stale++;
}
for (const mutant of policy.missingReasons) {
log(`WARN ${mutant.id}: browser guard не размечен — добавьте причину в `
+ '`docs/testing-notes/mutation-browser-guards.md` и конкретику в `because`');
warned++;
}
for (const id of policy.staleReasons) {
log(`WARN ${id}: browser-разметка устарела — такого browser guard больше нет`);
warned++;
}
} catch (error) {
log(`FAIL browser guard policy: ${error.message}`);
stale++;
}
if (warned) log(`предупреждений mutation registry: ${warned}`);
return stale ? 2 : 0;
}
+167
View File
@@ -13058,8 +13058,175 @@ const MUTANT_DEFINITIONS = [
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
const mutationEditorSource = readFileSync(join(repoRoot, 'src/houseplan-editor-runtime.ts'), 'utf8');
// #659: these witnesses exercise pure state/geometry/CSS contracts and do not
// need Chromium. Keep the grouping explicit: every promoted mutant is proven
// by `mutation-gate`, not merely reclassified to avoid a bundle build.
const unitGuard = (...files) => `node --test ${files.join(' ')} test/mutation-browser-offload.test.mjs`;
const UNIT_GUARD_GROUPS = [
[unitGuard('test/stairs.test.mjs'), [
'stairs-fixed-floor-still-navigates',
'stairs-link-auto-creates-target-object',
'stairs-broken-targets-become-active',
]],
[unitGuard('test/device-hit-owner.test.mjs'), [
'device-hit-scroll-observer-disabled',
]],
[unitGuard('test/summary-panel-runtime.test.mjs'), [
'summary-hide-unmounts-before-animation',
'summary-first-paint-shows-unavailable',
'summary-metrics-block-first-frame',
]],
[unitGuard('test/form-shell-consumers.test.mjs'), [
'discard-confirm-action-icon-falls-back-to-lock',
]],
[unitGuard('test/form-kit.test.mjs'), [
'range-line-clamps-every-keystroke',
]],
[unitGuard('test/dialog-baseline.test.mjs', 'test/styles-split.test.mjs'), [
'ha-form-shell-width-falls-back-to-generic',
'ha-form-shell-loses-canvas',
'ha-form-shell-mobile-keeps-desktop-inset',
]],
[unitGuard('test/summary-panel.test.mjs'), [
'summary-dialog-loses-wide-shell',
'summary-mobile-ignores-local-off',
]],
[unitGuard('test/styles-split.test.mjs'), [
'marker-shadow-animates-again',
]],
[unitGuard('test/device-inbox.test.mjs'), [
'device-inbox-batch-single-write',
]],
[unitGuard('test/houseplan-panel.test.mjs'), [
'panel-ignores-pre-upgrade-properties',
'panel-host-height-from-parent',
]],
[unitGuard('test/version-recovery-card.test.mjs'), [
'version-recovery-delays-config-capability-adoption',
]],
[unitGuard('test/vacuum-calibration-write.test.mjs'), [
'vacuum-manual-fit-after-proposal-uses-the-dock',
]],
[unitGuard('test/devices.test.mjs'), [
'room-climate-ignores-marker-placement',
]],
[unitGuard('test/device-inbox.test.mjs'), [
'device-tombstone-blocks-child-picker',
]],
[unitGuard('test/room-fit.test.mjs'), [
'room-fit-interactive-owner-leaks-through',
]],
[unitGuard('test/iso-stage6.test.mjs', 'test/styles-split.test.mjs'), [
'iso-tile-ring-visible',
'iso-tile-edge-zero',
'iso-tile-badge-gap-dropped',
'iso-tile-shadow-layer-above',
'iso-frame-selected-beats-focus',
'iso-shadow-in-forced-colors',
'iso-sun-length-ignores-elevation',
'iso-sun-streaks-on-dark-floor',
'iso-wall-top-constant',
]],
[unitGuard('test/backdrop-probe.test.mjs'), [
'backdrop-hard-demoted-to-warn',
]],
[unitGuard('test/bundle-assets.test.mjs'), [
'css-minifier-eats-required-space',
]],
[unitGuard('test/wall-thickness.test.mjs', 'test/wall-union-isolation.test.mjs'), [
'wall-component-failure-kills-primary',
]],
[unitGuard('test/glow-scene.test.mjs'), [
'union-failure-silent',
'glow-fail-dark-weakened',
'opening-cut-degenerate',
'column-shadow-removed',
'feather-20px',
]],
[unitGuard('test/visual-continuity.test.mjs'), [
'continuity-long-resume-noop',
]],
[unitGuard('test/ha-binding-status.test.mjs'), [
'registryless-opening-requires-registry-row',
]],
[unitGuard('test/header-menu.test.mjs'), [
'header-menu-item-keeps-menu-open',
'header-menu-escape-ignored',
'active-tab-not-revealed',
]],
[unitGuard('test/iso-scene-render.test.mjs', 'test/iso-stage6.test.mjs'), [
'grid-scale-iso-height-unscaled',
'stage3-w8-material-defs-created-per-face',
]],
[unitGuard('test/support-feedback.test.mjs'), [
'support-edited-retry-reuses-old-idempotency-key',
]],
[unitGuard('test/touch-gesture-click-guard.test.mjs'), [
'touch-pinch-contextmenu-guard-removed',
]],
[unitGuard('test/live-editor.test.mjs'), [
'live-editor-devices-drops-align-guides',
'live-editor-decor-drops-align-guides',
'live-editor-plan-drops-align-guides',
'live-editor-keeps-the-settled-guides-visible',
]],
[unitGuard('test/live-viewport.test.mjs'), [
'live-pan-incoming-edge-clipped',
'live-pan-rewrites-viewbox-every-frame',
]],
[unitGuard('test/summary-runtime-loader.test.mjs'), [
'summary-runtime-attaches-after-first-render',
]],
[unitGuard('test/config-reload-authority.test.mjs'), [
'config-reload-drops-asset-gap-ownership',
]],
[unitGuard('test/summary-panel.test.mjs'), [
'summary-picker-renders-unbounded-results',
]],
[unitGuard('test/form-shell-consumers.test.mjs', 'test/dialog-baseline.test.mjs',
'test/space-dialog.test.mjs'), [
'same-binding-click-resets-source',
'state-callout-hidden-under-help',
'dialog-card-loses-its-heading',
'form-kit-writes-to-a-neighbour-key',
'space-fill-segment-writes-a-neighbour-key',
'space-save-enabled-without-changes',
'space-discard-without-asking',
'space-layer-toggle-loses-inversion',
'general-save-enabled-without-changes',
'general-tile-opacity-writes-a-neighbour-key',
'general-north-clear-writes-zero',
'topology-embedded-draws-its-own-heading',
'room-save-enabled-without-changes',
'room-discard-without-asking',
'room-fill-segment-starts-at-none',
'room-temp-min-writes-max',
'marker-save-enabled-without-changes',
'marker-save-click-forgets-baseline-before-result',
'marker-discard-without-asking',
'marker-confirm-row-shown-for-do-nothing',
'marker-never-keeps-glow-block-live',
'marker-badge-position-forgets-touch',
]],
[unitGuard('test/color-picker.test.mjs'), [
'M-615-tile',
'M-615-plate',
]],
[unitGuard('test/form-kit.test.mjs', 'test/styles-split.test.mjs'), [
'form-kit-segment-breaks-words',
]],
[unitGuard('test/room-gear-drag.test.mjs'), [
'room-gear-drag-reopens-settings',
]],
];
const UNIT_GUARD_OVERRIDES = new Map(UNIT_GUARD_GROUPS.flatMap(([guard, ids]) => (
ids.map((id) => [id, guard])
)));
export const MUTANTS = MUTANT_DEFINITIONS.map((mutant) => ({
...mutant,
guard: UNIT_GUARD_OVERRIDES.get(mutant.id) ?? mutant.guard,
patches: mutant.patches.map((patch) => relocateEditorPatch(
patch, mutationCardSource, mutationEditorSource,
)),