perf: lazy-load editor runtime

Issue: #337
User-Visible: yes
This commit is contained in:
Matysh
2026-08-28 08:29:44 +03:00
parent 4a61407282
commit 59be9c8565
83 changed files with 32499 additions and 26465 deletions
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env node
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
export const INITIAL_VIEW_GZIP_BUDGET = 256_000;
export function assertBundleBudget(manifest, budget = INITIAL_VIEW_GZIP_BUDGET) {
if (manifest?.schema !== 1 || !Array.isArray(manifest.files)) {
throw new Error('invalid houseplan-assets.json');
}
if (!manifest.lazyEditorFiles?.length) {
throw new Error('bundle has no lazy editor graph');
}
if (manifest.initialViewFiles.some((path) => manifest.lazyEditorFiles.includes(path))) {
throw new Error('initial View graph overlaps lazy editor graph');
}
if (manifest.initialViewGzipBytes > budget) {
throw new Error(
`initial View graph ${manifest.initialViewGzipBytes} B gzip exceeds ${budget} B budget`,
);
}
return {
initialViewGzipBytes: manifest.initialViewGzipBytes,
lazyEditorGzipBytes: manifest.lazyEditorGzipBytes,
};
}
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
try {
const manifest = JSON.parse(readFileSync(resolve('dist/houseplan-assets.json'), 'utf8'));
const result = assertBundleBudget(manifest);
console.log(`initial View: ${result.initialViewGzipBytes} B gzip`);
console.log(`lazy editor: ${result.lazyEditorGzipBytes} B gzip`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}
+169
View File
@@ -0,0 +1,169 @@
import { createHash } from 'node:crypto';
import { existsSync, readFileSync, rmSync } from 'node:fs';
import { dirname, posix, relative, resolve } from 'node:path';
import { gzipSync } from 'node:zlib';
const BUILD_FINGERPRINT_TOKEN = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
const EDITOR_RETRY_ASSET_TOKEN = '__HOUSEPLAN_EDITOR_RETRY_ASSET__';
const ONBOARDING_RETRY_ASSET_TOKEN = '__HOUSEPLAN_ONBOARDING_RETRY_ASSET__';
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
const reachable = (entry, byPath, edge) => {
const seen = new Set();
const visit = (path) => {
if (seen.has(path)) return;
const file = byPath.get(path);
if (!file) throw new Error(`bundle manifest references missing asset: ${path}`);
seen.add(path);
for (const child of file[edge] || []) visit(child);
};
visit(entry);
return seen;
};
export function buildBundleManifest(bundle, fingerprint) {
const files = Object.values(bundle)
.filter((item) => item.type === 'chunk')
.map((chunk) => {
const contents = Buffer.from(chunk.code, 'utf8');
return {
path: chunk.fileName.replaceAll('\\', '/'),
sha256: sha256(contents),
rawBytes: contents.length,
gzipBytes: gzipSync(contents, { level: 9 }).length,
isEntry: chunk.isEntry,
imports: [...chunk.imports].sort(),
dynamicImports: [...chunk.dynamicImports].sort(),
};
})
.sort((left, right) => left.path.localeCompare(right.path));
const entry = files.find((file) => file.isEntry)?.path;
if (!entry) throw new Error('bundle manifest has no entry chunk');
const byPath = new Map(files.map((file) => [file.path, file]));
const initial = reachable(entry, byPath, 'imports');
const dynamicRoots = [...initial]
.flatMap((path) => byPath.get(path)?.dynamicImports || []);
const lazy = new Set();
for (const root of dynamicRoots) {
for (const path of reachable(root, byPath, 'imports')) if (!initial.has(path)) lazy.add(path);
}
const onboardingRoots = dynamicRoots.filter((path) => path.includes('houseplan-onboarding-runtime-'));
const editorRoots = dynamicRoots.filter((path) => !onboardingRoots.includes(path));
const graphFrom = (roots) => {
const graph = new Set();
for (const root of roots) {
for (const path of reachable(root, byPath, 'imports')) if (!initial.has(path)) graph.add(path);
}
return graph;
};
const lazyEditor = graphFrom(editorRoots);
const lazyOnboarding = graphFrom(onboardingRoots);
const sum = (paths) => [...paths]
.reduce((total, path) => total + (byPath.get(path)?.gzipBytes || 0), 0);
return {
schema: 1,
fingerprint,
entry,
initialViewFiles: [...initial].sort(),
initialViewGzipBytes: sum(initial),
lazyFiles: [...lazy].sort(),
lazyGzipBytes: sum(lazy),
lazyEditorFiles: [...lazyEditor].sort(),
lazyEditorGzipBytes: sum(lazyEditor),
lazyOnboardingFiles: [...lazyOnboarding].sort(),
lazyOnboardingGzipBytes: sum(lazyOnboarding),
files,
};
}
export function bundleManifestPlugin(fingerprint) {
return {
name: 'houseplan-bundle-manifest',
generateBundle(_options, bundle) {
const manifest = buildBundleManifest(bundle, fingerprint);
this.emitFile({
type: 'asset',
fileName: 'houseplan-assets.json',
source: `${JSON.stringify(manifest, null, 2)}\n`,
});
},
};
}
/** Embed one immutable build id in both sides of the lazy handshake. */
export function buildFingerprintPlugin(fingerprint) {
return {
name: 'houseplan-build-fingerprint',
transform(code, id) {
if (!id.replaceAll('\\', '/').includes('/src/')
|| !code.includes(BUILD_FINGERPRINT_TOKEN)) return null;
return {
code: code.replaceAll(BUILD_FINGERPRINT_TOKEN, fingerprint),
map: null,
};
},
};
}
/**
* Point the second import attempt at the exact content-hashed editor chunk.
* Rollup owns that filename, so the URL is injected only after chunk naming;
* the first import remains a normal Rollup edge for graph accounting.
*/
export function editorRuntimeRetryUrlPlugin() {
return {
name: 'houseplan-editor-retry-url',
generateBundle(_options, bundle) {
const chunks = Object.values(bundle).filter((item) => item.type === 'chunk');
const editor = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/houseplan-editor-runtime.ts')));
const onboarding = chunks.find((chunk) => Object.keys(chunk.modules)
.some((id) => id.replaceAll('\\', '/').endsWith('/src/houseplan-onboarding-runtime.ts')));
if (!editor) throw new Error('editor runtime chunk was not emitted');
if (!onboarding) throw new Error('onboarding runtime chunk was not emitted');
let editorReplacements = 0;
let onboardingReplacements = 0;
for (const chunk of chunks) {
if (chunk.code.includes(EDITOR_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), editor.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
editorReplacements += chunk.code.split(EDITOR_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(EDITOR_RETRY_ASSET_TOKEN, asset);
}
if (chunk.code.includes(ONBOARDING_RETRY_ASSET_TOKEN)) {
let asset = posix.relative(posix.dirname(chunk.fileName), onboarding.fileName);
if (!asset.startsWith('.')) asset = `./${asset}`;
onboardingReplacements += chunk.code.split(ONBOARDING_RETRY_ASSET_TOKEN).length - 1;
chunk.code = chunk.code.replaceAll(ONBOARDING_RETRY_ASSET_TOKEN, asset);
}
}
if (editorReplacements !== 1 || onboardingReplacements !== 1) {
throw new Error('lazy retry URL placeholder counts are '
+ `${editorReplacements}/${onboardingReplacements}, expected 1/1`);
}
},
};
}
/** Remove only files named by the previous generated manifest. */
export function cleanBundleOutputPlugin(outputRoot = 'dist') {
return {
name: 'houseplan-clean-bundle-output',
buildStart() {
const root = resolve(outputRoot);
const manifestPath = resolve(root, 'houseplan-assets.json');
if (!existsSync(manifestPath)) return;
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
for (const file of manifest?.files || []) {
const path = resolve(root, String(file.path || ''));
const rel = relative(root, path);
if (!rel || rel.startsWith('..') || rel.includes(':')) {
throw new Error(`previous bundle path escapes output root: ${file.path}`);
}
rmSync(path, { force: true });
}
rmSync(manifestPath, { force: true });
},
};
}
+69 -12
View File
@@ -9,24 +9,81 @@
* туда» жило шестью разными `cp` в воркфлоу и трижды в документации; когда
* копию забывали, смок врал согласованно (#236).
*/
import { copyFileSync, existsSync, mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { createHash } from 'node:crypto';
import {
copyFileSync, existsSync, mkdirSync, readFileSync, rmSync,
} from 'node:fs';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const SOURCE = resolve(ROOT, 'dist/houseplan-card.js');
const SOURCE_ROOT = resolve(ROOT, 'dist');
const MANIFEST_NAME = 'houseplan-assets.json';
const TARGETS = [
'custom_components/houseplan/frontend/houseplan-card.js',
'demo/srv/assets/houseplan-card.js',
'custom_components/houseplan/frontend',
'demo/srv/assets',
];
if (!existsSync(SOURCE)) {
console.error('dist/houseplan-card.js не найден: сначала `npm run build`');
const manifestPath = resolve(SOURCE_ROOT, MANIFEST_NAME);
if (!existsSync(manifestPath)) {
console.error(`dist/${MANIFEST_NAME} не найден: сначала \`npm run build\``);
process.exit(1);
}
for (const target of TARGETS) {
const path = resolve(ROOT, target);
mkdirSync(dirname(path), { recursive: true });
copyFileSync(SOURCE, path);
console.log(`бандл → ${target}`);
const readManifest = (path) => {
const parsed = JSON.parse(readFileSync(path, 'utf8'));
if (parsed?.schema !== 1 || !Array.isArray(parsed.files) || !parsed.entry) {
throw new Error(`${path}: invalid House Plan bundle manifest`);
}
return parsed;
};
const sourceManifest = readManifest(manifestPath);
const managedFiles = [MANIFEST_NAME, ...sourceManifest.files.map((file) => file.path)];
const sha256 = (path) => createHash('sha256').update(readFileSync(path)).digest('hex');
const contained = (root, name) => {
const path = resolve(root, name);
const rel = relative(root, path);
if (!rel || rel.startsWith('..') || rel.includes(':')) {
throw new Error(`bundle path escapes target root: ${name}`);
}
return path;
};
for (const file of sourceManifest.files) {
const path = contained(SOURCE_ROOT, file.path);
if (!existsSync(path)) throw new Error(`manifest asset is missing: ${file.path}`);
if (sha256(path) !== file.sha256) throw new Error(`manifest hash mismatch: ${file.path}`);
}
for (const target of TARGETS) {
const targetRoot = resolve(ROOT, target);
const oldManifestPath = resolve(targetRoot, MANIFEST_NAME);
let old = null;
if (existsSync(oldManifestPath)) {
old = readManifest(oldManifestPath);
}
// Content-hashed dependencies first, stable entry second, manifest last.
// At every observable point the current manifest therefore names a complete
// tree; an interrupted copy cannot publish an allowlist for absent chunks.
const payload = sourceManifest.files
.map((file) => file.path)
.sort((left, right) => (left === sourceManifest.entry ? 1 : 0)
- (right === sourceManifest.entry ? 1 : 0) || left.localeCompare(right));
for (const name of payload) {
const source = contained(SOURCE_ROOT, name);
const destination = contained(targetRoot, name);
mkdirSync(dirname(destination), { recursive: true });
copyFileSync(source, destination);
}
copyFileSync(manifestPath, oldManifestPath);
for (const file of old?.files || []) {
if (!managedFiles.includes(file.path)) rmSync(contained(targetRoot, file.path), { force: true });
}
const copied = readManifest(resolve(targetRoot, MANIFEST_NAME));
for (const file of copied.files) {
if (sha256(contained(targetRoot, file.path)) !== file.sha256) {
throw new Error(`${target}/${file.path}: copied hash mismatch`);
}
}
console.log(`бандл-дерево → ${target}`);
}
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
export const BUNDLE_MANIFEST = 'houseplan-assets.json';
export const sha256Bytes = (contents) => createHash('sha256').update(contents).digest('hex');
export function containedBundlePath(root, name) {
if (typeof name !== 'string' || !name || name.includes('\\')) {
throw new Error(`invalid bundle path: ${String(name)}`);
}
const path = resolve(root, name);
const rel = relative(resolve(root), path);
if (!rel || rel.startsWith('..') || rel.includes(':')) {
throw new Error(`bundle path escapes root: ${name}`);
}
return path;
}
export function readBundleManifest(root) {
const path = resolve(root, BUNDLE_MANIFEST);
if (!existsSync(path)) throw new Error(`${path}: bundle manifest is missing`);
const manifest = JSON.parse(readFileSync(path, 'utf8'));
if (manifest?.schema !== 1 || typeof manifest.entry !== 'string'
|| typeof manifest.fingerprint !== 'string' || !Array.isArray(manifest.files)) {
throw new Error(`${path}: invalid House Plan bundle manifest`);
}
const names = manifest.files.map((file) => file?.path);
if (new Set(names).size !== names.length || !names.includes(manifest.entry)) {
throw new Error(`${path}: duplicate assets or missing entry`);
}
return manifest;
}
export function verifyBundleTree(root) {
const manifest = readBundleManifest(root);
for (const file of manifest.files) {
if (typeof file?.path !== 'string' || !file.path.endsWith('.js')
|| !/^[a-zA-Z0-9._/-]+$/.test(file.path)) {
throw new Error(`invalid manifest asset path: ${String(file?.path)}`);
}
const path = containedBundlePath(root, file.path);
if (!existsSync(path)) throw new Error(`manifest asset is missing: ${file.path}`);
const actual = sha256Bytes(readFileSync(path));
if (actual !== file.sha256) {
throw new Error(`manifest hash mismatch: ${file.path} (${actual} != ${file.sha256})`);
}
}
return manifest;
}
export function compareBundleTrees(sourceRoot, targetRoot) {
const source = verifyBundleTree(sourceRoot);
const target = verifyBundleTree(targetRoot);
if (JSON.stringify(target) !== JSON.stringify(source)) {
throw new Error('bundle manifests differ');
}
for (const file of source.files) {
const left = readFileSync(containedBundlePath(sourceRoot, file.path));
const right = readFileSync(containedBundlePath(targetRoot, file.path));
if (!left.equals(right)) throw new Error(`bundle asset differs: ${file.path}`);
}
return source;
}
const invokedDirectly = process.argv[1]
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
try {
const [source, target] = process.argv.slice(2);
if (!source) throw new Error('usage: node scripts/bundle-tree.mjs <root> [matching-root]');
const manifest = target
? compareBundleTrees(resolve(source), resolve(target))
: verifyBundleTree(resolve(source));
console.log(`verified ${manifest.files.length} bundle assets`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}
+12 -3
View File
@@ -24,15 +24,24 @@ const requireWindow = (errors, source, index, pattern, label, before = 650) => {
export function checkCoordinateWriteBarriers(root = defaultRoot) {
const errors = [];
const frontend = readFileSync(resolve(root, 'src/houseplan-card.ts'), 'utf8');
// The editor implementation is a lazy chunk, but the persisted-coordinate
// inventory remains one logical frontend. Normalize the typed host port so
// the existing boundary windows keep describing the production call sites.
const card = readFileSync(resolve(root, 'src/houseplan-card.ts'), 'utf8');
const editor = readFileSync(resolve(root, 'src/houseplan-editor-runtime.ts'), 'utf8')
.replaceAll('this.host.', 'this.');
const frontend = `${card}\n${editor}`;
const configWrites = occurrences(frontend, "type: 'houseplan/config/set'");
if (configWrites.length !== 1) errors.push(`frontend config writer inventory: ${configWrites.length}`);
for (const index of configWrites) requireWindow(
errors, frontend, index,
/const candidate = canonicalizeConfigGeometry\(this\._serverCfg\);[\s\S]*config: candidate/,
'frontend config/set', 3600,
/const canonicalCandidate = canonicalizeConfigGeometry\(candidate\);[\s\S]*config: canonicalCandidate/,
'frontend config/set', 600,
);
if (occurrences(frontend, '._sendConfigCandidate(candidate)').length !== 2) {
errors.push('frontend canonical config transport callers changed');
}
const positionWrites = occurrences(frontend, "type: 'houseplan/layout/update'");
if (positionWrites.length !== 2) errors.push(`frontend position writer inventory: ${positionWrites.length}`);
+107
View File
@@ -0,0 +1,107 @@
/**
* Conservative minifier for static Lit css`` templates.
*
* This deliberately is not a general CSS optimiser: it removes comments and
* redundant whitespace while preserving strings, escapes and descendant
* combinators. Interpolated templates fail closed because rewriting across a
* JavaScript expression would make the build depend on runtime values.
*/
const isWhitespace = (char) => /[\t\n\f\r ]/.test(char);
const TIGHT_BEFORE = new Set(['{', '}', ':', ';', ',', '>', ')']);
const TIGHT_AFTER = new Set(['{', '}', ':', ';', ',', '>', '(']);
export function minifyCssText(source, label = '<css>') {
let out = '';
let quote = '';
let pendingSpace = false;
for (let i = 0; i < source.length; i += 1) {
const char = source[i];
const next = source[i + 1];
if (quote) {
out += char;
if (char === '\\') {
if (i + 1 >= source.length) throw new Error(`${label}: dangling CSS escape`);
out += source[++i];
} else if (char === quote) quote = '';
continue;
}
if (char === '"' || char === "'") {
if (pendingSpace && out && !TIGHT_AFTER.has(out.at(-1))) out += ' ';
pendingSpace = false;
quote = char;
out += char;
continue;
}
if (char === '/' && next === '*') {
const end = source.indexOf('*/', i + 2);
if (end < 0) throw new Error(`${label}: unclosed CSS comment`);
i = end + 1;
continue;
}
if (isWhitespace(char)) {
pendingSpace = true;
continue;
}
if (pendingSpace && out && !TIGHT_AFTER.has(out.at(-1)) && !TIGHT_BEFORE.has(char)) {
out += ' ';
}
pendingSpace = false;
if (TIGHT_BEFORE.has(char) && out.endsWith(' ')) out = out.slice(0, -1);
out += char;
}
if (quote) throw new Error(`${label}: unclosed CSS string`);
return out.trim();
}
export function minifyStaticCssTemplates(code, id = '<module>') {
let cursor = 0;
let output = '';
let changed = false;
while (true) {
const start = code.indexOf('css`', cursor);
if (start < 0) break;
let end = start + 4;
let escaped = false;
for (; end < code.length; end += 1) {
const char = code[end];
if (escaped) {
escaped = false;
continue;
}
if (char === '\\') {
escaped = true;
continue;
}
if (char === '$' && code[end + 1] === '{') {
throw new Error(`${id}:${end}: interpolated css template is not supported`);
}
if (char === '`') break;
}
if (end >= code.length) throw new Error(`${id}:${start}: unclosed css template`);
const css = code.slice(start + 4, end);
output += code.slice(cursor, start);
output += `css\`${minifyCssText(css, `${id}:${start}`)}\``;
cursor = end + 1;
changed = true;
}
if (!changed) return null;
return output + code.slice(cursor);
}
export function cssTemplateMinifier() {
return {
name: 'houseplan-css-template-minifier',
transform(code, id) {
if (!id.endsWith('.ts') || !code.includes('css`')) return null;
const transformed = minifyStaticCssTemplates(code, id);
return transformed == null ? null : { code: transformed, map: null };
},
};
}
+105 -7
View File
@@ -37,10 +37,96 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
const occurrenceCount = (source, needle) => source.split(needle).length - 1;
/**
* Map a logical pre-split card anchor onto the lazy editor source.
*
* Keeping the mutation definitions in their original logical form makes the
* registry readable and preserves the history of each regression. At runtime
* we relocate only anchors that actually moved into the typed editor host.
* The mapping is deliberately strict: an ambiguous or missing anchor still
* fails the cheap mutation applicability gate.
*/
function relocateEditorPatch(patch, cardSource, editorSource) {
if (patch.file !== 'src/houseplan-card.ts'
|| occurrenceCount(cardSource, patch.find) === 1) return patch;
if (occurrenceCount(editorSource, patch.find) === 1) {
return { ...patch, file: 'src/houseplan-editor-runtime.ts' };
}
const normalized = editorSource.replaceAll('this.host.', 'this.');
if (occurrenceCount(normalized, patch.find) !== 1) return patch;
const normalizedStart = normalized.indexOf(patch.find);
let rawIndex = 0;
let normalizedIndex = 0;
while (normalizedIndex < normalizedStart) {
if (editorSource.startsWith('this.host.', rawIndex)) {
rawIndex += 'this.host.'.length;
normalizedIndex += 'this.'.length;
} else {
rawIndex += 1;
normalizedIndex += 1;
}
}
const rawStart = rawIndex;
const hostFlags = [];
let findIndex = 0;
while (findIndex < patch.find.length) {
if (patch.find.startsWith('this.', findIndex)) {
hostFlags.push(editorSource.startsWith('this.host.', rawIndex));
}
if (editorSource.startsWith('this.host.', rawIndex)) {
rawIndex += 'this.host.'.length;
findIndex += 'this.'.length;
} else {
rawIndex += 1;
findIndex += 1;
}
}
const rawFind = editorSource.slice(rawStart, rawIndex);
let replacementThis = 0;
const rawReplace = patch.replace.replaceAll('this.', () => {
const host = hostFlags[replacementThis++] === true;
return host ? 'this.host.' : 'this.';
});
return {
...patch,
file: 'src/houseplan-editor-runtime.ts',
find: rawFind,
replace: rawReplace,
};
}
// --- реестр ---------------------------------------------------------------
// `find` обязан встречаться в файле ровно один раз: патч, который ложится «куда
// попало», проверяет не то, что объявлен проверять. Это контролирует --check.
export const MUTANTS = [
const MUTANT_DEFINITIONS = [
{
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 '
@@ -1527,9 +1613,9 @@ export const MUTANTS = [
guard: 'npm run bundle:sync && 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-card.ts',
find: ' private _openAlignDialog = (): void => this._previewAlignDialog(false);',
replace: ' private _openAlignDialog = (): void => {\n'
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'
+ ' };',
@@ -3432,8 +3518,16 @@ export const MUTANTS = [
},
];
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,
)),
}));
// --- механика ---------------------------------------------------------------
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
export function applyPatches(root, patches) {
for (const patch of patches) {
@@ -3493,7 +3587,8 @@ export function guardNeedsTestBuild(guard) {
* Нужен ли гварду СОБРАННЫЙ бандл (#332).
*
* Бандл читают только браузерные проверки: смоки и golden-сцены грузят
* `demo/srv/assets/houseplan-card.js`, и мутант обязан попасть в него, иначе
* дерево `demo/srv/assets/`, и мутант обязан попасть в entry и lazy chunks,
* иначе
* guard проверяет чистый код. Юнит- и бэкенд-гварды бандл не открывают ни в
* каком виде (проверено по реестру и по исходникам тестов: dist/** читается
* только как git-чекаут, который в worktree и так есть). Rollup-сборка —
@@ -3543,7 +3638,10 @@ function buildBundle(dir) {
if (built.status !== 0) {
throw new Error(`сборка мутанта упала:\n${(built.stderr || built.stdout).slice(-2000)}`);
}
cpSync(join(dir, 'dist', 'houseplan-card.js'), join(dir, 'demo', 'srv', 'assets', 'houseplan-card.js'));
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) {
+57 -27
View File
@@ -252,17 +252,33 @@ if (invokedDirectly) {
// Копия стенда (`demo/srv/assets`) больше не коммитится (#255): её собирает
// `npm run bundle:sync` перед браузерными прогонами. В релиз входят ровно
// две копии — артефакт сборки и та, что ставит HACS.
const names = [
'dist/houseplan-card.js',
'custom_components/houseplan/frontend/houseplan-card.js',
];
// Hash Git blobs, not checkout bytes. On Windows, Git can expose CRLF in
// the worktree while the exact tagged blobs and Linux release checkout use
// LF. The release must be bound to the immutable commit representation.
const hashes = names.map((name) => [name, sha256Bytes(committedFile(sha, name))]);
if (new Set(hashes.map(([, hash]) => hash)).size !== 1)
throw new Error(`Committed bundle snapshots differ: ${hashes.map(([name, hash]) => `${name}=${hash}`).join(', ')}`);
return hashes[0][1];
const distManifestBytes = committedFile(sha, 'dist/houseplan-assets.json');
const frontendManifestBytes = committedFile(
sha, 'custom_components/houseplan/frontend/houseplan-assets.json',
);
if (!distManifestBytes.equals(frontendManifestBytes)) {
throw new Error('Committed bundle manifests differ');
}
const manifest = JSON.parse(distManifestBytes.toString('utf8'));
if (manifest?.schema !== 1 || !Array.isArray(manifest.files)
|| manifest.entry !== 'houseplan-card.js') {
throw new Error('Committed bundle manifest is invalid');
}
for (const file of manifest.files) {
if (typeof file?.path !== 'string' || !file.path.endsWith('.js')
|| file.path.includes('..') || file.path.includes('\\')) {
throw new Error(`Committed bundle asset path is invalid: ${String(file?.path)}`);
}
const dist = committedFile(sha, `dist/${file.path}`);
const frontend = committedFile(sha, `custom_components/houseplan/frontend/${file.path}`);
if (!dist.equals(frontend)) throw new Error(`Committed bundle asset differs: ${file.path}`);
const actual = sha256Bytes(dist);
if (actual !== file.sha256) {
throw new Error(`Committed bundle asset hash mismatch: ${file.path}`);
}
}
const entry = manifest.files.find((file) => file.path === manifest.entry);
return { manifest, entrySha256: entry.sha256 };
};
const materializeCommittedBundle = (sha, expectedSha256, artifactsDir) => {
@@ -275,18 +291,31 @@ if (invokedDirectly) {
return bundlePath;
};
const verifyZipContents = (zipPath, version, bundleSha256) => {
const entries = readZipEntries(zipPath, ['manifest.json', 'frontend/houseplan-card.js']);
const verifyZipContents = (zipPath, version, bundleSnapshot) => {
const header = readZipEntries(zipPath, [
'manifest.json', 'frontend/houseplan-assets.json',
]);
const frontendManifest = JSON.parse(header.get('frontend/houseplan-assets.json').toString('utf8'));
if (JSON.stringify(frontendManifest) !== JSON.stringify(bundleSnapshot.manifest)) {
throw new Error('houseplan.zip frontend manifest differs from committed bundle');
}
const required = [
'manifest.json', 'frontend/houseplan-assets.json',
...bundleSnapshot.manifest.files.map((file) => `frontend/${file.path}`),
];
const entries = readZipEntries(zipPath, required);
const manifest = JSON.parse(entries.get('manifest.json').toString('utf8'));
if (manifest.version !== version)
throw new Error(`houseplan.zip manifest version ${manifest.version} != ${version}`);
const bundledHash = createHash('sha256')
.update(entries.get('frontend/houseplan-card.js')).digest('hex');
if (bundledHash !== bundleSha256)
throw new Error(`houseplan.zip frontend hash ${bundledHash} != committed bundle ${bundleSha256}`);
for (const file of bundleSnapshot.manifest.files) {
const bundledHash = sha256Bytes(entries.get(`frontend/${file.path}`));
if (bundledHash !== file.sha256) {
throw new Error(`houseplan.zip frontend hash mismatch: ${file.path}`);
}
}
};
const buildZip = (sha, version, bundleSha256, artifactsDir) => {
const buildZip = (sha, version, bundleSnapshot, artifactsDir) => {
const zipPath = resolve(artifactsDir, 'houseplan.zip');
run('git', [
// `git archive` on Windows otherwise applies local core.autocrlf and
@@ -294,11 +323,11 @@ if (invokedDirectly) {
'-c', 'core.autocrlf=false', 'archive', '--format=zip', `--output=${zipPath}`,
`${sha}:custom_components/houseplan`,
]);
verifyZipContents(zipPath, version, bundleSha256);
verifyZipContents(zipPath, version, bundleSnapshot);
return zipPath;
};
const verifyRemoteAssetContents = (version, bundleSha256) => {
const verifyRemoteAssetContents = (version, bundleSnapshot) => {
const download = mkdtempSync(resolve(tmpdir(), 'houseplan-release-check-'));
try {
run('gh', [
@@ -308,9 +337,9 @@ if (invokedDirectly) {
try {
const cardPath = resolve(download, 'houseplan-card.js');
const cardHash = sha256Path(cardPath);
if (cardHash !== bundleSha256)
throw new Error(`Published houseplan-card.js hash ${cardHash} != candidate ${bundleSha256}`);
verifyZipContents(resolve(download, 'houseplan.zip'), version, bundleSha256);
if (cardHash !== bundleSnapshot.entrySha256)
throw new Error(`Published houseplan-card.js hash ${cardHash} != candidate ${bundleSnapshot.entrySha256}`);
verifyZipContents(resolve(download, 'houseplan.zip'), version, bundleSnapshot);
} catch (error) {
throw new ReleaseAssetContentError(
error instanceof Error ? error.message : String(error),
@@ -466,7 +495,8 @@ if (invokedDirectly) {
const sha = run('git', ['rev-parse', 'HEAD']).stdout;
const remoteBranch = run('git', ['rev-parse', `origin/${branch}`]).stdout;
if (sha !== remoteBranch) throw new Error(`HEAD ${sha} is not synchronized with origin/${branch} ${remoteBranch}`);
const bundleSha256 = assertBundleSnapshots(sha);
const bundleSnapshot = assertBundleSnapshots(sha);
const bundleSha256 = bundleSnapshot.entrySha256;
const validateRuns = assertGreenValidate(sha);
validateIssues();
const existingTag = remoteTag();
@@ -519,7 +549,7 @@ if (invokedDirectly) {
// A matching name and non-zero size are insufficient: bind both
// downloadable assets to this exact candidate before closing issues.
try {
verifyRemoteAssetContents(contract.version, bundleSha256);
verifyRemoteAssetContents(contract.version, bundleSnapshot);
} catch (error) {
if (!(error instanceof ReleaseAssetContentError)) throw error;
console.log(`Published release needs stale-asset recovery: ${error.message}`);
@@ -534,7 +564,7 @@ if (invokedDirectly) {
}
}
const zipPath = buildZip(sha, contract.version, bundleSha256, artifactsDir);
const zipPath = buildZip(sha, contract.version, bundleSnapshot, artifactsDir);
if (!existingTag.exists) {
const local = localTag();
if (local.exists && (!local.annotated || local.commit !== sha)) {
@@ -577,7 +607,7 @@ if (invokedDirectly) {
const finalTag = remoteTag();
if (!finalTag.exists || finalTag.commit !== sha)
throw new Error(`Published tag ${tag} no longer resolves to exact SHA ${sha}`);
verifyRemoteAssetContents(contract.version, bundleSha256);
verifyRemoteAssetContents(contract.version, bundleSnapshot);
verifyHacsDiscovery();
finishIssues(published.url);
console.log(`Published and content-verified: ${published.url}`);
+10
View File
@@ -34,6 +34,16 @@ export const SMOKE_LINKS = [
+ 'fail-closed candidate) through the rendered card, never naming the key-quantisation '
+ 'internals that decide which nodes are one node (#331 §2.1)',
},
{
symbols: [
'EditorRuntimeLoader', 'EditorRuntimeLoaderState', 'EditorRuntimeModule',
'EDITOR_RUNTIME_FINGERPRINT', 'createHouseplanEditorRuntime',
],
smokes: ['smoke_lazy_editor_chunk.mjs'],
because: 'the production-bundle scenario proves that the content-hashed editor chunk stays '
+ 'off the View network path, is shared by all three editors, retries once, and refuses an '
+ 'incompatible build before editor state is installed (#337)',
},
{
symbols: [
'placeResizeAreaLabel', 'resizeInwardNormal', 'resizeMeasuredEdges',
+2
View File
@@ -18,6 +18,8 @@ const BUILD_INPUTS = [
'rollup.config.mjs',
'tsconfig.json',
'scripts/source-fingerprint.mjs',
'scripts/css-template-minifier.mjs',
'scripts/bundle-manifest.mjs',
];
const fingerprintFiles = (root) => {
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env node
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { readZipEntries } from './release-prerelease.mjs';
import { readBundleManifest, sha256Bytes } from './bundle-tree.mjs';
export function verifyHouseplanZip(zipPath, bundleRoot, expectedVersion = '') {
const expected = readBundleManifest(bundleRoot);
const header = readZipEntries(zipPath, [
'manifest.json', 'frontend/houseplan-assets.json',
]);
const integration = JSON.parse(header.get('manifest.json').toString('utf8'));
if (expectedVersion && integration.version !== expectedVersion) {
throw new Error(`houseplan.zip manifest version ${integration.version} != ${expectedVersion}`);
}
const bundled = JSON.parse(header.get('frontend/houseplan-assets.json').toString('utf8'));
if (JSON.stringify(bundled) !== JSON.stringify(expected)) {
throw new Error('houseplan.zip frontend manifest differs from source tree');
}
const entries = readZipEntries(zipPath, expected.files.map((file) => `frontend/${file.path}`));
for (const file of expected.files) {
if (sha256Bytes(entries.get(`frontend/${file.path}`)) !== file.sha256) {
throw new Error(`houseplan.zip frontend hash mismatch: ${file.path}`);
}
}
return { version: integration.version, files: expected.files.length };
}
const invokedDirectly = process.argv[1]
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
if (invokedDirectly) {
try {
const [zip, root, version = ''] = process.argv.slice(2);
if (!zip || !root) {
throw new Error('usage: node scripts/verify-houseplan-zip.mjs <zip> <bundle-root> [version]');
}
const result = verifyHouseplanZip(resolve(zip), resolve(root), version);
console.log(`verified houseplan.zip ${result.version}: ${result.files} frontend assets`);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}