Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 143 additions & 3 deletions .github/scripts/analyze-pr.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
const fs = require('node:fs');
const path = require('node:path');
const { execSync } = require('node:child_process');
const {
gzipBytes: gzipTextBytes,
measureComponentImport,
bundleInputsByEntry,
packageStylesheetPath,
} = require('./lib/component-size');
const {
COMPONENT_PACKAGES,
componentExportsFromBarrel,
Expand Down Expand Up @@ -194,7 +200,17 @@ function componentExistsInBase(pkg, componentName) {
});
return true;
} catch {
return false;
// A nested component folder without an index barrel still exists in base.
if (pkg.layout === 'flat') return false;
try {
const listing = execSync(
`git ls-tree --name-only ${baseBranch} -- ${pkgSrc(pkg)}/${componentName}/`,
{ encoding: 'utf8', stdio: 'pipe' },
);
return listing.trim().length > 0;
} catch {
return false;
}
}
}

Expand Down Expand Up @@ -500,6 +516,113 @@ function getComplexity(pkg, componentName) {
}
}

// A package's prebuilt stylesheet text, read once per tree.
const stylesheetCache = new Map();
function packageStylesheet(pkg) {
const file = packageStylesheetPath(path.join(process.cwd(), pkg.dir));
if (!file) return null;
if (!stylesheetCache.has(file)) stylesheetCache.set(file, fs.readFileSync(file, 'utf8'));
return stylesheetCache.get(file);
}

// Every canonical public component of a package, with the dist module that
// defines its export.
function packageTargets(pkg) {
if (pkg.layout === 'flat') {
return getComponentNames(pkg).map((name) => ({
key: `${pkg.packageName}/${name}`,
pkg,
name,
distModule: `${name}.js`,
}));
}
const sourceDir = path.join(process.cwd(), pkgSrc(pkg));
const exports = componentExportsFromBarrel(path.join(sourceDir, 'index.ts'), sourceDir);
const targets = [];
for (const name of nestedPackageComponentNames(process.cwd(), pkg)) {
const file = exports.get(name);
if (!file) continue;
targets.push({
key: `${pkg.packageName}/${name}`,
pkg,
name,
distModule: path.relative(sourceDir, file).replace(/\.tsx?$/, '.js'),
});
}
return targets;
}

// Every canonical public component a change can affect: its own folder or
// file changed, or its bundle, import() targets included, reaches a changed
// module of its package, such as a shared hook or style module. Family folders
// that hold several components yield each of them.
function sizeTargets(changedFiles) {
const withoutExtension = (file) => file.replace(/\.(tsx?|jsx?|mjs)$/, '');
const targets = [];
for (const pkg of PACKAGES) {
const prefix = pkgSrc(pkg) + '/';
const changedModules = new Set(
changedFiles
.filter((file) => file.startsWith(prefix))
.map((file) => withoutExtension(file.slice(prefix.length))),
);
if (changedModules.size === 0) continue;
const changedTops = new Set([...changedModules].map((file) => file.split('/')[0]));
const dist = path.join(process.cwd(), pkgDist(pkg));
// esbuild reports inputs by real path.
const distReal = fs.existsSync(dist) ? fs.realpathSync(dist) : dist;
const all = packageTargets(pkg);
const reach = bundleInputsByEntry(all.map((target) => path.join(dist, target.distModule)));
for (const target of all) {
const top = withoutExtension(target.distModule.split('/')[0]);
const inputs = reach?.get(path.join(dist, target.distModule)) ?? [];
const reachesChange = inputs.some((input) => {
const relative = path.relative(distReal, input);
return !relative.startsWith('..') && changedModules.has(withoutExtension(relative));
});
if (changedTops.has(top) || reachesChange) targets.push(target);
}
}
return targets;
}

// Targets from either tree, once each by key.
function unionTargets(first, second) {
const byKey = new Map(first.map((target) => [target.key, target]));
for (const target of second) if (!byKey.has(target.key)) byKey.set(target.key, target);
return [...byKey.values()];
}

// What each target's import costs in the current tree: the gzip JavaScript it
// loads eagerly, and the gzip CSS of the package stylesheet rules referenced by
// its own package's modules it can load, import() targets included (spec:AST-080 FR8,
// architecture:component-style-authoring INV6).
function measureSizes(targets) {
const sizes = {};
for (const target of targets) {
const dist = path.join(process.cwd(), pkgDist(target.pkg));
let size = measureComponentImport(
path.join(dist, target.distModule),
packageStylesheet(target.pkg),
dist,
target.name,
);
// An alias (`export {A as B}`) is not exported by its defining module;
// measure it through the package entry instead.
if (size.importGzipBytes === null) {
const {esmPath} = resolveEntries(dist, target.pkg);
size = measureComponentImport(
esmPath,
packageStylesheet(target.pkg),
dist,
target.name,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ContextMenuItem is exported as DropdownMenuItem as ContextMenuItem (packages/core/src/ContextMenu/index.ts:21), so this passes a name DropdownMenu/DropdownMenuItem.js does not export; esbuild fails with “No matching export” and the row reads N/A in all six columns on every DropdownMenu change. Bundling the name the module exports (the sourceName that namedExportItems parses), or falling back to export *, would give it figures.

);
}
sizes[target.key] = {package: target.pkg.name, ...size};
}
return sizes;
}

// Get component stats
function getComponentStats(pkg, componentName) {
// Per-component dist sizing only applies to nested packages that emit a
Expand All @@ -512,7 +635,6 @@ function getComponentStats(pkg, componentName) {
const entries = distDir ? resolveEntries(distDir, pkg) : { esmPath: null, cjsPath: null };
const loc = getComponentLOC(pkg, componentName);
const complexity = getComplexity(pkg, componentName);

return {
package: pkg.name,
esmSize: getFileSize(entries.esmPath),
Expand Down Expand Up @@ -545,6 +667,10 @@ function getPackageBundleStats(pkg) {
cjsBytes: getFileSizeBytes(cjsPath),
gzipSize: formatBytes(gzipBytes),
gzipBytes,
stylesheetGzipBytes: (() => {
const css = packageStylesheet(pkg);
return css === null ? null : gzipTextBytes(css);
})(),
};
}

Expand Down Expand Up @@ -655,6 +781,12 @@ function analyze() {
// and package lists stay the head's; only the stats are read in the base tree.
let baseComponentStats = null;
let baseBundlePackages = null;
// Components a change can affect, by reach in the head build and, when the
// merge base is built, in the base build too: CI's size-base job builds only
// the base, and a module change can add or remove reach.
const headTargets = sizeTargets(changedFiles);
let targets = headTargets;
let baseComponentSizes = null;
if (baseRoot) {
const headDir = process.cwd();
process.chdir(path.resolve(headDir, baseRoot));
Expand All @@ -667,17 +799,24 @@ function analyze() {
baseBundlePackages = PACKAGES.filter((p) => changedPackages.has(p.name)).map((p) =>
getPackageBundleStats(p),
);
// Each tree is measured through its own defining modules, so a renamed
// module is not mistaken for a missing one.
const baseTargets = sizeTargets(changedFiles);
baseComponentSizes = measureSizes(unionTargets(baseTargets, headTargets));
targets = unionTargets(headTargets, baseTargets);
} finally {
process.chdir(headDir);
}
}
const componentSizes = baseOnly ? null : measureSizes(targets);

if (baseOnly) {
const baseResult = {
headSha: execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim(),
baseSha: execSync('git rev-parse HEAD', { cwd: baseRoot, encoding: 'utf8' }).trim(),
baseComponentStats,
baseBundlePackages,
baseComponentSizes,
analyzedAt: new Date().toISOString(),
};
fs.writeFileSync(outputFile, JSON.stringify(baseResult, null, 2));
Expand All @@ -701,7 +840,8 @@ function analyze() {
// or render it in the report should caveat when this is 'two-dot'.
diffMode,
bundlePackages,
...(baseRoot ? { baseComponentStats, baseBundlePackages } : {}),
componentSizes,
...(baseRoot ? { baseComponentStats, baseBundlePackages, baseComponentSizes } : {}),
// Back-compat: keep totalBundle pointed at core when core changed, so any
// older consumer of this field still resolves.
totalBundle: bundlePackages.find((b) => b.package === '@astryxdesign/core') || null,
Expand Down
Loading
Loading