mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-04 02:00:10 +00:00
Source and performance budgets shared fatal exits with correctness checks, so ordinary growth could block otherwise valid changes. Centralize limit severity and GitHub annotations/summaries, keep local checks strict, and carry warnings across native lint, container, and BuildKit boundaries. Keep semantic lint, types, API inventories, source ownership, invalid measurements, process failures, and runner admission guards blocking. Validation: P2 review clean; three Linux owner-configuration replays and 20 standalone policy runs passed; typechecks, targeted type-aware lint, Knip export scans, workflow checks, formatting, and diff checks passed. The changed gate found a test environment typing error, corrected and verified by the root-test typecheck. One unrelated preparation process cleanup failure did not reproduce in its diagnostic replay; no fix claimed.
702 lines
27 KiB
TypeScript
702 lines
27 KiB
TypeScript
#!/usr/bin/env node
|
|
// Reports and enforces compressed Control UI asset budgets after a production build.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import process from "node:process";
|
|
import { fileURLToPath } from "node:url";
|
|
import { reportLimitViolations } from "./lib/check-limits.mts";
|
|
import { CONTROL_UI_LOCALE_ENTRIES } from "./lib/control-ui-i18n-config.ts";
|
|
|
|
function isMetricsRecord(value: unknown): value is Record<string, unknown> {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
const KIB = 1024;
|
|
const STARTUP_JS_BASELINE_RATCHET_BYTES = 4096;
|
|
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
const DEFAULT_STARTUP_BUDGET_BASELINE_PATH = path.resolve(
|
|
SCRIPT_DIR,
|
|
"../config/control-ui-startup-budget-baseline.json",
|
|
);
|
|
|
|
// Each landed change can consume this much ratchet tolerance, so small increases
|
|
// may accumulate. The fixed startup JS ceiling bounds that cumulative creep.
|
|
const CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES = 512;
|
|
const CONTROL_UI_STARTUP_JS_GZIP_BUILD_VARIANCE_BYTES = 64;
|
|
const CONTROL_UI_STARTUP_CSS_GZIP_TARGET_BYTES = 45 * KIB;
|
|
// Immediate Home and diagnostic frames approved in #147574, including shared header styles.
|
|
const CONTROL_UI_CSS_GZIP_GROWTH_BYTES = 1.5 * KIB;
|
|
// The opaque Mermaid sandbox loads one self-contained classic script only when
|
|
// a diagram is viewed. Keep its size visible without relaxing ordinary chunks.
|
|
const MERMAID_RENDERER_ASSET = /^assets\/mermaid\.min-[\w-]+\.js$/u;
|
|
const MERMAID_RENDERER_GZIP_BYTES = 960 * KIB;
|
|
// Locale catalogs are named Vite chunks loaded only after a language selection.
|
|
// Base and config-hint chunks share one budget because they activate atomically.
|
|
const CONTROL_UI_LOCALE_ASSET_PATTERNS = CONTROL_UI_LOCALE_ENTRIES.flatMap(({ locale }) => [
|
|
{
|
|
locale,
|
|
kind: "base" as const,
|
|
pattern: new RegExp(`^assets/${escapeRegExp(locale)}-[^/]+\\.js$`, "u"),
|
|
},
|
|
{
|
|
locale,
|
|
kind: "configHints" as const,
|
|
pattern: new RegExp(`^assets/locale-config-hints-${escapeRegExp(locale)}-[^/]+\\.js$`, "u"),
|
|
},
|
|
]);
|
|
const CONTROL_UI_LOCALE_GZIP_BYTES = 300 * KIB;
|
|
|
|
// Small, explicit headroom over the optimized baseline. Budget changes should
|
|
// accompany an intentional loading or chunking decision.
|
|
const controlUiPerformanceBudgets = {
|
|
startupJsRequests: 18,
|
|
startupCssRequests: 1,
|
|
// Current main plus destination diagnostics measures 370,756 B; retain the fixed allowances.
|
|
startupJsGzipBytes: 370_756,
|
|
// Keep 45 KiB advisory: tiny integrated changes must not exhaust the budget.
|
|
// The fixed 50 KiB ceiling bounds accumulation of small changes.
|
|
startupCssGzipBytes: 50 * KIB,
|
|
largestJsGzipBytes: 215 * KIB,
|
|
// Composer multiline surface (stack #124301) legitimately grew boot CSS;
|
|
// operator decision 2026-08-25 rejected boot splitting due to precedence risk.
|
|
// 53.0 KiB was exhausted by organic growth (main sat at 99.94% by 2026-08-29);
|
|
// bumped to 53.5 KiB with operator approval on PR #132054. 2026-09-02: side
|
|
// panel, workboard chip, and Lobsterdex styles moved to their lazy owners,
|
|
// measured boot sheet 52,337 B; ceiling lowered to keep ~1 KiB headroom.
|
|
largestCssGzipBytes: 53_400,
|
|
} satisfies Record<string, number>;
|
|
export const CONTROL_UI_PERFORMANCE_BUDGETS = Object.freeze(controlUiPerformanceBudgets);
|
|
|
|
function controlUiAssetPathFromUrl(value: string): string | null {
|
|
const normalized = value.split(/[?#]/u, 1)[0]?.replace(/\\/gu, "/") ?? "";
|
|
const markerIndex = normalized.lastIndexOf("assets/");
|
|
if (markerIndex === -1) {
|
|
return null;
|
|
}
|
|
const assetPath = normalized.slice(markerIndex);
|
|
if (assetPath.includes("../") || !/\.(?:css|js)$/u.test(assetPath)) {
|
|
return null;
|
|
}
|
|
return assetPath;
|
|
}
|
|
|
|
export function extractControlUiStartupAssetPaths(html: string): string[] {
|
|
const assets = new Set<string>();
|
|
for (const tag of html.matchAll(/<(?:link|script)\b[^>]*>/giu)) {
|
|
const attribute = tag[0].match(/\s(?:href|src)\s*=\s*["']([^"']+)["']/iu);
|
|
const assetPath = attribute?.[1] ? controlUiAssetPathFromUrl(attribute[1]) : null;
|
|
if (assetPath) {
|
|
assets.add(assetPath);
|
|
}
|
|
}
|
|
return [...assets].toSorted((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
function readAssetMetrics(assetsDir: string, entry: fs.Dirent) {
|
|
const file = `assets/${entry.name}`;
|
|
const sourcePath = path.join(assetsDir, entry.name);
|
|
const gzipPath = `${sourcePath}.gz`;
|
|
const brotliPath = `${sourcePath}.br`;
|
|
for (const sidecarPath of [gzipPath, brotliPath]) {
|
|
if (!fs.existsSync(sidecarPath)) {
|
|
throw new Error(`Control UI performance check missing ${path.basename(sidecarPath)}`);
|
|
}
|
|
}
|
|
const type = entry.name.endsWith(".js") ? "js" : "css";
|
|
return {
|
|
file,
|
|
type,
|
|
rawBytes: fs.statSync(sourcePath).size,
|
|
gzipBytes: fs.statSync(gzipPath).size,
|
|
brotliBytes: fs.statSync(brotliPath).size,
|
|
};
|
|
}
|
|
|
|
function summarizeAssets(assets: Array<ReturnType<typeof readAssetMetrics>>) {
|
|
return assets.reduce(
|
|
(summary, asset) => ({
|
|
requests: summary.requests + 1,
|
|
rawBytes: summary.rawBytes + asset.rawBytes,
|
|
gzipBytes: summary.gzipBytes + asset.gzipBytes,
|
|
brotliBytes: summary.brotliBytes + asset.brotliBytes,
|
|
}),
|
|
{ requests: 0, rawBytes: 0, gzipBytes: 0, brotliBytes: 0 },
|
|
);
|
|
}
|
|
|
|
function largestAsset(assets: Array<ReturnType<typeof readAssetMetrics>>) {
|
|
return assets.toSorted(
|
|
(left, right) => right.gzipBytes - left.gzipBytes || left.file.localeCompare(right.file),
|
|
)[0]!;
|
|
}
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
}
|
|
|
|
function controlUiLocaleAssetIdentity(
|
|
file: string,
|
|
): { locale: string; kind: "base" | "configHints" } | null {
|
|
const match = CONTROL_UI_LOCALE_ASSET_PATTERNS.find(({ pattern }) => pattern.test(file));
|
|
return match ? { locale: match.locale, kind: match.kind } : null;
|
|
}
|
|
|
|
function collectControlUiLocaleAssetGroups(
|
|
assets: Array<ReturnType<typeof readAssetMetrics>>,
|
|
): Array<{
|
|
locale: string;
|
|
base: Array<ReturnType<typeof readAssetMetrics>>;
|
|
configHints: Array<ReturnType<typeof readAssetMetrics>>;
|
|
}> {
|
|
const groups = new Map<
|
|
string,
|
|
{
|
|
locale: string;
|
|
base: Array<ReturnType<typeof readAssetMetrics>>;
|
|
configHints: Array<ReturnType<typeof readAssetMetrics>>;
|
|
}
|
|
>();
|
|
for (const asset of assets) {
|
|
const identity = controlUiLocaleAssetIdentity(asset.file);
|
|
if (!identity) {
|
|
continue;
|
|
}
|
|
const group = groups.get(identity.locale) ?? {
|
|
locale: identity.locale,
|
|
base: [],
|
|
configHints: [],
|
|
};
|
|
group[identity.kind].push(asset);
|
|
groups.set(identity.locale, group);
|
|
}
|
|
return [...groups.values()];
|
|
}
|
|
|
|
export function collectControlUiPerformanceMetrics(distDir: string) {
|
|
const assetsDir = path.join(distDir, "assets");
|
|
const html = fs.readFileSync(path.join(distDir, "index.html"), "utf8");
|
|
const assets = fs
|
|
.readdirSync(assetsDir, { withFileTypes: true })
|
|
.filter((entry) => entry.isFile() && /\.(?:css|js)$/u.test(entry.name))
|
|
.map((entry) => readAssetMetrics(assetsDir, entry));
|
|
const assetsByFile = new Map(assets.map((asset) => [asset.file, asset]));
|
|
const startup = extractControlUiStartupAssetPaths(html).map((file) => {
|
|
const asset = assetsByFile.get(file);
|
|
if (!asset) {
|
|
throw new Error(`Control UI performance check cannot find startup asset ${file}`);
|
|
}
|
|
return asset;
|
|
});
|
|
const jsAssets = assets.filter((asset) => asset.type === "js");
|
|
const mermaidRenderer = jsAssets.filter((asset) => MERMAID_RENDERER_ASSET.test(asset.file));
|
|
const localeCatalogs = jsAssets.filter(
|
|
(asset) => controlUiLocaleAssetIdentity(asset.file) !== null,
|
|
);
|
|
const ordinaryJsAssets = jsAssets.filter(
|
|
(asset) =>
|
|
!MERMAID_RENDERER_ASSET.test(asset.file) && controlUiLocaleAssetIdentity(asset.file) === null,
|
|
);
|
|
const cssAssets = assets.filter((asset) => asset.type === "css");
|
|
if (ordinaryJsAssets.length === 0 || cssAssets.length === 0 || startup.length === 0) {
|
|
throw new Error("Control UI performance check found an incomplete production bundle");
|
|
}
|
|
return {
|
|
schemaVersion: 1 as const,
|
|
startup: {
|
|
js: summarizeAssets(startup.filter((asset) => asset.type === "js")),
|
|
css: summarizeAssets(startup.filter((asset) => asset.type === "css")),
|
|
assets: startup,
|
|
},
|
|
total: {
|
|
js: summarizeAssets(jsAssets),
|
|
css: summarizeAssets(cssAssets),
|
|
},
|
|
largest: {
|
|
js: largestAsset(ordinaryJsAssets),
|
|
css: largestAsset(cssAssets),
|
|
},
|
|
mermaidRenderer,
|
|
localeCatalogs,
|
|
};
|
|
}
|
|
|
|
export function evaluateControlUiPerformanceBudgets(
|
|
metrics: ReturnType<typeof collectControlUiPerformanceMetrics>,
|
|
budgets: Readonly<typeof CONTROL_UI_PERFORMANCE_BUDGETS> = CONTROL_UI_PERFORMANCE_BUDGETS,
|
|
startupBudgetBaseline: Readonly<ControlUiStartupBudgetBaseline> | null = null,
|
|
startupJsTolerance = CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES,
|
|
baseMetrics: ReturnType<typeof collectControlUiPerformanceMetrics> | null = null,
|
|
) {
|
|
const baselineBytes = startupBudgetBaseline?.startupJsGzipBytes;
|
|
const startupJsLimits = resolveControlUiStartupJsGzipLimits(
|
|
budgets,
|
|
startupBudgetBaseline,
|
|
startupJsTolerance,
|
|
);
|
|
const localeGroups = collectControlUiLocaleAssetGroups(metrics.localeCatalogs);
|
|
const checks: Array<[string, number, number, "count" | "bytes"]> = [
|
|
["startup JS requests", metrics.startup.js.requests, budgets.startupJsRequests, "count"],
|
|
["startup CSS requests", metrics.startup.css.requests, budgets.startupCssRequests, "count"],
|
|
["startup JS gzip", metrics.startup.js.gzipBytes, startupJsLimits.enforcementLimit, "bytes"],
|
|
["startup CSS gzip", metrics.startup.css.gzipBytes, budgets.startupCssGzipBytes, "bytes"],
|
|
["largest JS gzip", metrics.largest.js.gzipBytes, budgets.largestJsGzipBytes, "bytes"],
|
|
["largest CSS gzip", metrics.largest.css.gzipBytes, budgets.largestCssGzipBytes, "bytes"],
|
|
["isolated Mermaid JS assets", metrics.mermaidRenderer.length, 1, "count"],
|
|
[
|
|
"isolated Mermaid JS gzip",
|
|
summarizeAssets(metrics.mermaidRenderer).gzipBytes,
|
|
MERMAID_RENDERER_GZIP_BYTES,
|
|
"bytes",
|
|
],
|
|
[
|
|
"startup Mermaid JS assets",
|
|
metrics.startup.assets.filter((asset) => MERMAID_RENDERER_ASSET.test(asset.file)).length,
|
|
0,
|
|
"count",
|
|
],
|
|
[
|
|
"locale catalog JS assets",
|
|
metrics.localeCatalogs.length,
|
|
CONTROL_UI_LOCALE_ENTRIES.length * 2,
|
|
"count",
|
|
],
|
|
[
|
|
"locale catalog base JS assets per locale",
|
|
Math.max(0, ...localeGroups.map((group) => group.base.length)),
|
|
1,
|
|
"count",
|
|
],
|
|
[
|
|
"locale config-hint JS assets per locale",
|
|
Math.max(0, ...localeGroups.map((group) => group.configHints.length)),
|
|
1,
|
|
"count",
|
|
],
|
|
[
|
|
"largest locale catalog pair JS gzip",
|
|
Math.max(
|
|
0,
|
|
...localeGroups.map(
|
|
(group) => summarizeAssets([...group.base, ...group.configHints]).gzipBytes,
|
|
),
|
|
),
|
|
CONTROL_UI_LOCALE_GZIP_BYTES,
|
|
"bytes",
|
|
],
|
|
[
|
|
"startup locale catalog JS assets",
|
|
metrics.startup.assets.filter((asset) => controlUiLocaleAssetIdentity(asset.file) !== null)
|
|
.length,
|
|
0,
|
|
"count",
|
|
],
|
|
];
|
|
const violations = checks.flatMap(([metric, actual, limit, unit]) =>
|
|
actual > limit ? [{ metric, actual, limit, unit }] : [],
|
|
);
|
|
if (baseMetrics) {
|
|
for (const area of ["startup", "largest"] as const) {
|
|
const growth = metrics[area].css.gzipBytes - baseMetrics[area].css.gzipBytes;
|
|
if (growth >= CONTROL_UI_CSS_GZIP_GROWTH_BYTES) {
|
|
violations.push({
|
|
metric: `${area} CSS gzip growth`,
|
|
actual: growth,
|
|
limit: CONTROL_UI_CSS_GZIP_GROWTH_BYTES - 1,
|
|
unit: "bytes",
|
|
});
|
|
}
|
|
}
|
|
}
|
|
if (baselineBytes !== undefined && baselineBytes > budgets.startupJsGzipBytes) {
|
|
violations.unshift({
|
|
metric: "startup JS gzip baseline",
|
|
actual: baselineBytes,
|
|
limit: budgets.startupJsGzipBytes,
|
|
unit: "bytes",
|
|
});
|
|
}
|
|
return violations;
|
|
}
|
|
|
|
function resolveControlUiStartupJsGzipLimits(
|
|
budgets: Readonly<typeof CONTROL_UI_PERFORMANCE_BUDGETS>,
|
|
startupBudgetBaseline: Readonly<ControlUiStartupBudgetBaseline> | null,
|
|
startupJsTolerance: number,
|
|
) {
|
|
const baselineCap = budgets.startupJsGzipBytes;
|
|
if (!startupBudgetBaseline) {
|
|
return {
|
|
baselineCap,
|
|
growthLimit: baselineCap,
|
|
buildVariance: 0,
|
|
enforcementLimit: baselineCap,
|
|
};
|
|
}
|
|
const growthLimit =
|
|
Math.min(startupBudgetBaseline.startupJsGzipBytes, baselineCap) + startupJsTolerance;
|
|
return {
|
|
baselineCap,
|
|
growthLimit,
|
|
buildVariance: CONTROL_UI_STARTUP_JS_GZIP_BUILD_VARIANCE_BYTES,
|
|
enforcementLimit: growthLimit + CONTROL_UI_STARTUP_JS_GZIP_BUILD_VARIANCE_BYTES,
|
|
};
|
|
}
|
|
|
|
type ControlUiPerformanceBudgetViolation = ReturnType<
|
|
typeof evaluateControlUiPerformanceBudgets
|
|
>[number];
|
|
|
|
function formatControlUiPerformanceBytes(bytes: number): string {
|
|
return bytes < KIB ? `${bytes} B` : `${(bytes / KIB).toFixed(1)} KiB`;
|
|
}
|
|
|
|
function formatRequestCount(count: number): string {
|
|
return `${count} ${count === 1 ? "request" : "requests"}`;
|
|
}
|
|
|
|
function formatAssetSummary(summary: ReturnType<typeof summarizeAssets>): string {
|
|
return `${formatRequestCount(summary.requests)}, ${formatControlUiPerformanceBytes(summary.gzipBytes)} gzip (${summary.gzipBytes} B), ${formatControlUiPerformanceBytes(summary.brotliBytes)} br, ${summary.rawBytes} B raw`;
|
|
}
|
|
|
|
function controlUiPerformanceWarnings(
|
|
metrics: ReturnType<typeof collectControlUiPerformanceMetrics>,
|
|
budgets: Readonly<typeof CONTROL_UI_PERFORMANCE_BUDGETS>,
|
|
): string[] {
|
|
const warnings: string[] = [];
|
|
if (metrics.startup.css.gzipBytes > CONTROL_UI_STARTUP_CSS_GZIP_TARGET_BYTES) {
|
|
warnings.push(
|
|
`startup CSS gzip is above the ${CONTROL_UI_STARTUP_CSS_GZIP_TARGET_BYTES} B advisory target; remaining hard-ceiling headroom: ${budgets.startupCssGzipBytes - metrics.startup.css.gzipBytes} B`,
|
|
);
|
|
}
|
|
const largestCssHeadroom = budgets.largestCssGzipBytes - metrics.largest.css.gzipBytes;
|
|
if (largestCssHeadroom < CONTROL_UI_CSS_GZIP_GROWTH_BYTES) {
|
|
warnings.push(`largest CSS gzip has ${largestCssHeadroom} B of hard-ceiling headroom`);
|
|
}
|
|
return warnings;
|
|
}
|
|
|
|
function formatViolation(violation: ControlUiPerformanceBudgetViolation): string {
|
|
const actual =
|
|
violation.unit === "bytes"
|
|
? formatControlUiPerformanceBytes(violation.actual)
|
|
: String(violation.actual);
|
|
const limit =
|
|
violation.unit === "bytes"
|
|
? formatControlUiPerformanceBytes(violation.limit)
|
|
: String(violation.limit);
|
|
const exactBytes =
|
|
violation.unit === "bytes" && actual === limit
|
|
? ` (${violation.actual} B vs ${violation.limit} B)`
|
|
: "";
|
|
return `${violation.metric}: ${actual} exceeds ${limit}${exactBytes}`;
|
|
}
|
|
|
|
export function formatControlUiPerformanceReport(
|
|
metrics: ReturnType<typeof collectControlUiPerformanceMetrics>,
|
|
budgets: Readonly<typeof CONTROL_UI_PERFORMANCE_BUDGETS> = CONTROL_UI_PERFORMANCE_BUDGETS,
|
|
startupBudgetBaseline: Readonly<ControlUiStartupBudgetBaseline> | null = null,
|
|
startupJsTolerance: number = CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES,
|
|
baseMetrics: ReturnType<typeof collectControlUiPerformanceMetrics> | null = null,
|
|
): string {
|
|
const startupJsLimits = resolveControlUiStartupJsGzipLimits(
|
|
budgets,
|
|
startupBudgetBaseline,
|
|
startupJsTolerance,
|
|
);
|
|
const violations = evaluateControlUiPerformanceBudgets(
|
|
metrics,
|
|
budgets,
|
|
startupBudgetBaseline,
|
|
startupJsTolerance,
|
|
baseMetrics,
|
|
);
|
|
const lines = [
|
|
"Control UI performance:",
|
|
` measurement: shipped gzip sidecars; Node ${process.version}`,
|
|
` startup JS: ${formatAssetSummary(metrics.startup.js)} (limits: ${formatRequestCount(budgets.startupJsRequests)}, ${formatControlUiPerformanceBytes(startupJsLimits.enforcementLimit)} gzip / ${startupJsLimits.enforcementLimit} B)`,
|
|
];
|
|
if (startupBudgetBaseline) {
|
|
lines.push(
|
|
` startup JS gzip vs baseline: ${metrics.startup.js.gzipBytes} B (baseline ${startupBudgetBaseline.startupJsGzipBytes} B + growth allowance ${startupJsTolerance} B = growth limit ${startupJsLimits.growthLimit} B; build-variance allowance ${startupJsLimits.buildVariance} B; enforcement limit ${startupJsLimits.enforcementLimit} B; max committed baseline ${startupJsLimits.baselineCap} B)`,
|
|
);
|
|
}
|
|
lines.push(
|
|
` startup CSS: ${formatAssetSummary(metrics.startup.css)} (limits: ${formatRequestCount(budgets.startupCssRequests)}, advisory target ${CONTROL_UI_STARTUP_CSS_GZIP_TARGET_BYTES} B, hard ceiling ${budgets.startupCssGzipBytes} B; headroom ${budgets.startupCssGzipBytes - metrics.startup.css.gzipBytes} B)`,
|
|
` largest ordinary JS: ${metrics.largest.js.file}, ${formatControlUiPerformanceBytes(metrics.largest.js.gzipBytes)} gzip (limit: ${formatControlUiPerformanceBytes(budgets.largestJsGzipBytes)})`,
|
|
` largest CSS: ${metrics.largest.css.file}, ${metrics.largest.css.gzipBytes} B gzip (hard ceiling ${budgets.largestCssGzipBytes} B; headroom ${budgets.largestCssGzipBytes - metrics.largest.css.gzipBytes} B)`,
|
|
` all JS: ${formatAssetSummary(metrics.total.js)}`,
|
|
` all CSS: ${formatAssetSummary(metrics.total.css)}`,
|
|
);
|
|
if (baseMetrics) {
|
|
for (const area of ["startup", "largest"] as const) {
|
|
const growth = metrics[area].css.gzipBytes - baseMetrics[area].css.gzipBytes;
|
|
lines.push(
|
|
` ${area} CSS gzip vs base: ${baseMetrics[area].css.gzipBytes} B -> ${metrics[area].css.gzipBytes} B (${growth >= 0 ? "+" : ""}${growth} B; growth below ${CONTROL_UI_CSS_GZIP_GROWTH_BYTES} B allowed)`,
|
|
);
|
|
}
|
|
}
|
|
for (const warning of controlUiPerformanceWarnings(metrics, budgets)) {
|
|
lines.push(` warning: ${warning}`);
|
|
}
|
|
if (metrics.mermaidRenderer.length > 0) {
|
|
lines.push(
|
|
` isolated Mermaid JS: ${formatAssetSummary(summarizeAssets(metrics.mermaidRenderer))} (limits: 1 deferred asset, ${formatControlUiPerformanceBytes(MERMAID_RENDERER_GZIP_BYTES)} gzip; forbidden at startup)`,
|
|
);
|
|
}
|
|
if (metrics.localeCatalogs.length > 0) {
|
|
const localeGroups = collectControlUiLocaleAssetGroups(metrics.localeCatalogs);
|
|
const largestLocalePair = localeGroups.toSorted(
|
|
(left, right) =>
|
|
summarizeAssets([...right.base, ...right.configHints]).gzipBytes -
|
|
summarizeAssets([...left.base, ...left.configHints]).gzipBytes ||
|
|
left.locale.localeCompare(right.locale),
|
|
)[0]!;
|
|
const largestLocalePairSummary = summarizeAssets([
|
|
...largestLocalePair.base,
|
|
...largestLocalePair.configHints,
|
|
]);
|
|
lines.push(
|
|
` locale catalog JS: ${formatAssetSummary(summarizeAssets(metrics.localeCatalogs))} (largest pair: ${largestLocalePair.locale}, ${formatControlUiPerformanceBytes(largestLocalePairSummary.gzipBytes)} gzip; limits: 1 base + 1 config-hint asset per locale, ${formatControlUiPerformanceBytes(CONTROL_UI_LOCALE_GZIP_BYTES)} combined; forbidden at startup)`,
|
|
);
|
|
}
|
|
if (
|
|
startupBudgetBaseline &&
|
|
metrics.startup.js.gzipBytes + STARTUP_JS_BASELINE_RATCHET_BYTES <
|
|
startupBudgetBaseline.startupJsGzipBytes
|
|
) {
|
|
lines.push(
|
|
` hint: startup JS gzip is more than ${STARTUP_JS_BASELINE_RATCHET_BYTES} B below the ${startupBudgetBaseline.startupJsGzipBytes} B baseline; lower it with ${baselineUpdateCommand()}`,
|
|
);
|
|
}
|
|
if (violations.length > 0) {
|
|
lines.push(
|
|
" violations:",
|
|
...violations.map((violation) => ` - ${formatViolation(violation)}`),
|
|
);
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function baselineUpdateCommand(): string {
|
|
return 'node --import ./scripts/tsx.mjs scripts/check-control-ui-performance.mts --update-baseline --reason "<reason>"';
|
|
}
|
|
|
|
function isIsoDate(value: string): boolean {
|
|
if (!/^\d{4}-\d{2}-\d{2}$/u.test(value)) {
|
|
return false;
|
|
}
|
|
const date = new Date(`${value}T00:00:00Z`);
|
|
return !Number.isNaN(date.valueOf()) && date.toISOString().slice(0, 10) === value;
|
|
}
|
|
|
|
function readControlUiStartupBudgetBaseline(baselinePath: string): ControlUiStartupBudgetBaseline {
|
|
try {
|
|
const parsed: unknown = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
|
|
const record: Record<string, unknown> = isMetricsRecord(parsed) ? parsed : {};
|
|
const { startupJsGzipBytes, reason, updatedAt } = record;
|
|
if (
|
|
typeof startupJsGzipBytes !== "number" ||
|
|
!Number.isSafeInteger(startupJsGzipBytes) ||
|
|
startupJsGzipBytes < 0 ||
|
|
typeof reason !== "string" ||
|
|
reason.trim().length === 0 ||
|
|
typeof updatedAt !== "string" ||
|
|
!isIsoDate(updatedAt)
|
|
) {
|
|
throw new Error(
|
|
"expected non-negative integer startupJsGzipBytes, non-empty reason, and YYYY-MM-DD updatedAt",
|
|
);
|
|
}
|
|
return { startupJsGzipBytes, reason, updatedAt };
|
|
} catch (error) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
throw new Error(
|
|
`Cannot read Control UI startup budget baseline ${baselinePath}: ${detail}. Regenerate it with ${baselineUpdateCommand()}.`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
}
|
|
|
|
function writeControlUiStartupBudgetBaseline(
|
|
baselinePath: string,
|
|
startupJsGzipBytes: number,
|
|
reason: string,
|
|
) {
|
|
if (startupJsGzipBytes > CONTROL_UI_PERFORMANCE_BUDGETS.startupJsGzipBytes) {
|
|
throw new Error("startup JS gzip baseline exceeds the committed-baseline cap");
|
|
}
|
|
const baseline = {
|
|
startupJsGzipBytes,
|
|
reason,
|
|
updatedAt: new Date().toISOString().slice(0, 10),
|
|
};
|
|
fs.writeFileSync(baselinePath, `${JSON.stringify(baseline, null, 2)}\n`);
|
|
return baseline;
|
|
}
|
|
|
|
type ControlUiStartupBudgetBaseline = ReturnType<typeof writeControlUiStartupBudgetBaseline>;
|
|
|
|
function validateExplicitStartupJsBytes(
|
|
startupJsBytes: number,
|
|
currentBaseline: ControlUiStartupBudgetBaseline,
|
|
): void {
|
|
const delta = Math.abs(startupJsBytes - currentBaseline.startupJsGzipBytes);
|
|
if (delta > STARTUP_JS_BASELINE_RATCHET_BYTES) {
|
|
throw new Error(
|
|
`startup JS gzip baseline update: ${startupJsBytes} B differs from current baseline ${currentBaseline.startupJsGzipBytes} B by ${delta} B, exceeding the ${STARTUP_JS_BASELINE_RATCHET_BYTES} B ratchet`,
|
|
);
|
|
}
|
|
}
|
|
|
|
export function runControlUiPerformanceCheck(
|
|
distDir: string,
|
|
budgets: Readonly<typeof CONTROL_UI_PERFORMANCE_BUDGETS> = CONTROL_UI_PERFORMANCE_BUDGETS,
|
|
baselinePath = DEFAULT_STARTUP_BUDGET_BASELINE_PATH,
|
|
baseDistDir?: string,
|
|
) {
|
|
const startupBudgetBaseline = readControlUiStartupBudgetBaseline(baselinePath);
|
|
const metrics = collectControlUiPerformanceMetrics(distDir);
|
|
const baseMetrics = baseDistDir ? collectControlUiPerformanceMetrics(baseDistDir) : null;
|
|
const violations = evaluateControlUiPerformanceBudgets(
|
|
metrics,
|
|
budgets,
|
|
startupBudgetBaseline,
|
|
undefined,
|
|
baseMetrics,
|
|
);
|
|
const report = formatControlUiPerformanceReport(
|
|
metrics,
|
|
budgets,
|
|
startupBudgetBaseline,
|
|
undefined,
|
|
baseMetrics,
|
|
);
|
|
return {
|
|
metrics,
|
|
baseMetrics,
|
|
budgets,
|
|
startupBudgetBaseline,
|
|
startupJsTolerance: CONTROL_UI_STARTUP_JS_GZIP_TOLERANCE_BYTES,
|
|
startupJsBuildVariance: CONTROL_UI_STARTUP_JS_GZIP_BUILD_VARIANCE_BYTES,
|
|
violations,
|
|
warnings: controlUiPerformanceWarnings(metrics, budgets),
|
|
report,
|
|
};
|
|
}
|
|
|
|
function main(argv: string[] = process.argv.slice(2)): void {
|
|
let json = false;
|
|
let reportOnly = false;
|
|
let baseDistDir: string | undefined;
|
|
let updateBaseline = false;
|
|
let reason: string | undefined;
|
|
let startupJsBytes: number | undefined;
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--json") {
|
|
json = true;
|
|
} else if (arg === "--report-only") {
|
|
reportOnly = true;
|
|
} else if (arg === "--base-dist") {
|
|
const value = argv[++index];
|
|
if (!value || value.startsWith("--")) {
|
|
throw new Error("--base-dist requires a directory");
|
|
}
|
|
baseDistDir = path.resolve(value);
|
|
} else if (arg === "--update-baseline") {
|
|
updateBaseline = true;
|
|
} else if (arg === "--reason") {
|
|
reason = argv[index + 1];
|
|
if (!reason || reason.trim().length === 0 || reason.startsWith("--")) {
|
|
throw new Error("--reason requires a non-empty value");
|
|
}
|
|
index += 1;
|
|
} else if (arg === "--startup-js-bytes") {
|
|
const value = argv[index + 1];
|
|
if (!value || !/^[1-9]\d*$/u.test(value)) {
|
|
throw new Error("--startup-js-bytes requires a positive integer");
|
|
}
|
|
startupJsBytes = Number(value);
|
|
if (!Number.isSafeInteger(startupJsBytes)) {
|
|
throw new Error("--startup-js-bytes requires a positive integer");
|
|
}
|
|
index += 1;
|
|
} else {
|
|
throw new Error(`Unknown option: ${arg}`);
|
|
}
|
|
}
|
|
if (reason !== undefined && !updateBaseline) {
|
|
throw new Error("--reason requires --update-baseline");
|
|
}
|
|
if (startupJsBytes !== undefined && !updateBaseline) {
|
|
throw new Error("--startup-js-bytes requires --update-baseline");
|
|
}
|
|
if (json && updateBaseline) {
|
|
throw new Error("--json cannot be combined with --update-baseline");
|
|
}
|
|
if (updateBaseline && (reportOnly || baseDistDir)) {
|
|
throw new Error("--report-only and --base-dist cannot be combined with --update-baseline");
|
|
}
|
|
const distDir = path.resolve(SCRIPT_DIR, "../dist/control-ui");
|
|
if (updateBaseline) {
|
|
if (startupJsBytes !== undefined) {
|
|
const currentBaseline = readControlUiStartupBudgetBaseline(
|
|
DEFAULT_STARTUP_BUDGET_BASELINE_PATH,
|
|
);
|
|
validateExplicitStartupJsBytes(startupJsBytes, currentBaseline);
|
|
}
|
|
const nextStartupJsBytes =
|
|
startupJsBytes ?? collectControlUiPerformanceMetrics(distDir).startup.js.gzipBytes;
|
|
const baseline = writeControlUiStartupBudgetBaseline(
|
|
DEFAULT_STARTUP_BUDGET_BASELINE_PATH,
|
|
nextStartupJsBytes,
|
|
reason ?? "manual baseline update",
|
|
);
|
|
process.stdout.write(
|
|
`Updated config/control-ui-startup-budget-baseline.json to ${baseline.startupJsGzipBytes} B (${baseline.reason}).\n`,
|
|
);
|
|
return;
|
|
}
|
|
const result = runControlUiPerformanceCheck(distDir, undefined, undefined, baseDistDir);
|
|
if (json) {
|
|
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
} else {
|
|
process.stdout.write(`${result.report}\n`);
|
|
}
|
|
if (!reportOnly) {
|
|
const artifactContractMetrics = new Set([
|
|
"isolated Mermaid JS assets",
|
|
"startup Mermaid JS assets",
|
|
"locale catalog base JS assets per locale",
|
|
"locale config-hint JS assets per locale",
|
|
"startup locale catalog JS assets",
|
|
]);
|
|
const limitsFailed = reportLimitViolations(
|
|
result.violations
|
|
.filter((violation) => !artifactContractMetrics.has(violation.metric))
|
|
.map((violation) => ({
|
|
file:
|
|
violation.metric === "startup JS gzip baseline"
|
|
? "config/control-ui-startup-budget-baseline.json"
|
|
: "scripts/check-control-ui-performance.mts",
|
|
title: "Control UI asset budget",
|
|
message: formatViolation(violation),
|
|
})),
|
|
);
|
|
if (
|
|
limitsFailed ||
|
|
result.violations.some((violation) => artifactContractMetrics.has(violation.metric))
|
|
) {
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
}
|
|
}
|