mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user