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
110 changes: 86 additions & 24 deletions .github/scripts/analyze-pr.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@

/**
* @description Analyzes PR changes to detect new/modified components and gather stats
* @input --base <branch> --head <ref> --output <file>
* @output JSON file with component analysis; exits non-zero if the base...head diff fails (e.g. shallow clone without a merge base)
* @input --base <branch> --head <ref> --output <file> [--base-root <dir> [--base-only]]
* @output JSON file with component analysis; exits non-zero if the base...head diff fails (e.g. shallow clone without a merge base).
* With --base-root (a built checkout of the merge base), the analysis also
* records baseComponentStats and baseBundlePackages measured in that tree;
* --base-only writes just those, with the head and base commits they belong to.
*/

const fs = require('node:fs');
Expand All @@ -28,6 +31,12 @@ const getArg = (name) => {
const baseBranch = getArg('base') || 'main';
const headRef = getArg('head') || 'HEAD';
const outputFile = getArg('output') || 'analysis.json';
const baseRoot = getArg('base-root');
const baseOnly = args.includes('--base-only');
if (baseOnly && !baseRoot) {
console.error('::error::--base-only needs --base-root');
process.exit(1);
}

const STORYBOOK_STORIES = 'apps/storybook/stories';

Expand Down Expand Up @@ -236,32 +245,50 @@ function getFileSizeBytes(filePath) {
}
}

// Get gzipped size of a file. Returns null (→ "N/A") when the file is absent,
// instead of the old behavior of gzipping a nonexistent path and reporting a
// bogus "0B".
function getGzipSize(filePath) {
// Gzipped byte count of a file, or null when the file is absent — never a
// bogus 0 for a path that does not exist.
function getGzipBytes(filePath) {
if (!filePath || !fs.existsSync(filePath)) return null;
try {
const output = execSync(`gzip -c "${filePath}" | wc -c`, { encoding: 'utf8' });
const bytes = parseInt(output.trim(), 10);
if (!Number.isFinite(bytes) || bytes <= 0) return null;
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)}MB`;
return Number.isFinite(bytes) && bytes > 0 ? bytes : null;
} catch {
return null;
}
}

// Resolve a package/component's dist entry files. The build emits CJS-style
// `.js` today (no `.mjs`); we probe for both so the report stays correct if an
// ESM artifact is added later, and never measures a file that isn't there.
function resolveEntries(distDir) {
const esm = path.join(distDir, 'index.mjs');
const cjs = path.join(distDir, 'index.js');
function formatBytes(bytes) {
if (bytes === null) return null;
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)}MB`;
}

// Whether a package's `.js` files are ES modules (`"type": "module"`).
function packageIsModule(pkg) {
try {
const manifest = path.join(process.cwd(), pkg.dir, 'package.json');
return JSON.parse(fs.readFileSync(manifest, 'utf8')).type === 'module';
} catch {
return false;
}
}

// Resolve a package/component's dist entry files by module format. Babel
// writes ES modules with a `.js` extension in `"type": "module"` packages, so
// there `index.js` is the ESM entry; a package that is not a module ships ESM
// as `index.mjs` and CommonJS as `index.js`. `index.mjs`/`index.cjs` are ESM
// and CommonJS wherever they exist. A missing file is never measured.
function resolveEntries(distDir, pkg) {
const existing = (name) => {
const file = path.join(distDir, name);
return fs.existsSync(file) ? file : null;
};
const isModule = packageIsModule(pkg);
return {
esmPath: fs.existsSync(esm) ? esm : null,
cjsPath: fs.existsSync(cjs) ? cjs : null,
esmPath: existing('index.mjs') || (isModule ? existing('index.js') : null),
cjsPath: existing('index.cjs') || (isModule ? null : existing('index.js')),
};
}

Expand Down Expand Up @@ -482,7 +509,7 @@ function getComponentStats(pkg, componentName) {
pkg.layout === 'flat'
? null
: path.join(process.cwd(), pkgDist(pkg), componentName);
const entries = distDir ? resolveEntries(distDir) : { esmPath: null, cjsPath: null };
const entries = distDir ? resolveEntries(distDir, pkg) : { esmPath: null, cjsPath: null };
const loc = getComponentLOC(pkg, componentName);
const complexity = getComplexity(pkg, componentName);

Expand All @@ -507,17 +534,17 @@ function getComponentStats(pkg, componentName) {
// Get total bundle stats for a package's published entry.
function getPackageBundleStats(pkg) {
const distDir = path.join(process.cwd(), pkgDist(pkg));
const { esmPath, cjsPath } = resolveEntries(distDir);
// Gzip the primary published entry — prefer ESM if it exists, else the CJS
// `.js` the build actually emits today. Never gzip a missing path.
const primary = esmPath || cjsPath;
const { esmPath, cjsPath } = resolveEntries(distDir, pkg);
// Gzip the primary published entry: ESM when it exists, else CommonJS.
const gzipBytes = getGzipBytes(esmPath || cjsPath);
return {
package: pkg.name,
esmSize: getFileSize(esmPath),
esmBytes: getFileSizeBytes(esmPath),
cjsSize: getFileSize(cjsPath),
cjsBytes: getFileSizeBytes(cjsPath),
gzipSize: getGzipSize(primary),
gzipSize: formatBytes(gzipBytes),
gzipBytes,
};
}

Expand Down Expand Up @@ -624,6 +651,40 @@ function analyze() {
getPackageBundleStats(p),
);

// Base measurements come from a built checkout of the merge base. Component
// and package lists stay the head's; only the stats are read in the base tree.
let baseComponentStats = null;
let baseBundlePackages = null;
if (baseRoot) {
const headDir = process.cwd();
process.chdir(path.resolve(headDir, baseRoot));
try {
baseComponentStats = {};
for (const key of modifiedComponents) {
const pkg = PACKAGES.find((p) => p.name === componentStats[key].package);
baseComponentStats[key] = getComponentStats(pkg, key);
}
baseBundlePackages = PACKAGES.filter((p) => changedPackages.has(p.name)).map((p) =>
getPackageBundleStats(p),
);
} finally {
process.chdir(headDir);
}
}

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,
analyzedAt: new Date().toISOString(),
};
fs.writeFileSync(outputFile, JSON.stringify(baseResult, null, 2));
console.log(`Base stats written to ${outputFile}`);
return baseResult;
}

const result = {
newComponents,
modifiedComponents,
Expand All @@ -640,6 +701,7 @@ function analyze() {
// or render it in the report should caveat when this is 'two-dot'.
diffMode,
bundlePackages,
...(baseRoot ? { baseComponentStats, baseBundlePackages } : {}),
// 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
116 changes: 116 additions & 0 deletions .github/scripts/analyze-pr.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -279,3 +279,119 @@ describe('analyze-pr component ownership', () => {
}
});
});

describe('analyze-pr base and head sizes', () => {
it('measures ESM entries by module type and reads base sizes from a merge-base build', () => {
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-analyze-size-'));
const baseRoot = path.join(repo, '.size-base');
const write = (root, file, content) => {
fs.mkdirSync(path.dirname(path.join(root, file)), {recursive: true});
fs.writeFileSync(path.join(root, file), content);
};
try {
write(repo, 'package.json', '{}');
write(repo, '.gitignore', 'dist/\n.size-base/\n*.json.out\n');
write(repo, 'packages/core/package.json', '{"type": "module"}\n');
write(
repo,
'packages/core/src/index.ts',
"export {Card} from './Card';\n",
);
write(
repo,
'packages/core/src/Card/index.ts',
"export {Card} from './Card';\n",
);
write(
repo,
'packages/core/src/Card/Card.tsx',
'export function Card() {}\n',
);
write(
repo,
'packages/core/src/Card/Card.doc.mjs',
"export const docs = {\n name: 'Card',\n};\n",
);
git(repo, ['init', '-q', '-b', 'main']);
git(repo, ['config', 'user.email', 'test@test.co']);
git(repo, ['config', 'user.name', 'Test']);
git(repo, ['add', '-A']);
git(repo, ['commit', '-qm', 'base']);
git(repo, ['worktree', 'add', '-q', '--detach', baseRoot, 'HEAD']);
git(repo, ['checkout', '-q', '-b', 'feature']);
fs.appendFileSync(
path.join(repo, 'packages/core/src/Card/Card.tsx'),
'export const changed = true;\n',
);
git(repo, ['commit', '-qam', 'touch Card']);

// Babel writes ES modules as `.js` in a "type": "module" package.
write(baseRoot, 'packages/core/dist/Card/index.js', 'x'.repeat(100));
write(
baseRoot,
'packages/core/dist/index.js',
'export const base = 1;\n'.repeat(10),
);
write(repo, 'packages/core/dist/Card/index.js', 'x'.repeat(160));
write(
repo,
'packages/core/dist/index.js',
'export const head = 2;\n'.repeat(40),
);

execFileSync(
process.execPath,
[
SCRIPT,
'--base',
'main',
'--head',
'HEAD',
'--base-root',
'.size-base',
'--output',
'analysis.json',
],
{cwd: repo, encoding: 'utf8'},
);
const analysis = JSON.parse(
fs.readFileSync(path.join(repo, 'analysis.json'), 'utf8'),
);
expect(analysis.componentStats.Card.esmBytes).toBe(160);
expect(analysis.componentStats.Card.cjsBytes).toBeNull();
expect(analysis.baseComponentStats.Card.esmBytes).toBe(100);
const [headBundle] = analysis.bundlePackages;
const [baseBundle] = analysis.baseBundlePackages;
expect(headBundle.package).toBe('@astryxdesign/core');
expect(baseBundle.package).toBe('@astryxdesign/core');
expect(headBundle.gzipBytes).toBeGreaterThan(0);
expect(baseBundle.gzipBytes).toBeGreaterThan(0);

execFileSync(
process.execPath,
[
SCRIPT,
'--base',
'main',
'--head',
'HEAD',
'--base-root',
'.size-base',
'--base-only',
'--output',
'size-base.json',
],
{cwd: repo, encoding: 'utf8'},
);
const baseOnly = JSON.parse(
fs.readFileSync(path.join(repo, 'size-base.json'), 'utf8'),
);
expect(baseOnly.headSha).toBe(git(repo, ['rev-parse', 'HEAD']));
expect(baseOnly.baseSha).toBe(git(repo, ['rev-parse', 'main']));
expect(baseOnly.baseComponentStats.Card.esmBytes).toBe(100);
expect(baseOnly.componentStats).toBeUndefined();
} finally {
fs.rmSync(repo, {recursive: true, force: true});
}
});
});
3 changes: 3 additions & 0 deletions .github/scripts/ci-tooling-routing.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@ describe('Node-tooling CI routing', () => {
step(ci.jobs['test-build'], 'Run Build CSS-layer cascade guard').run,
).toBe('node .github/scripts/theme-layer-cascade.js');
expect(ci.jobs['fixture-contrast'].if).toContain(TOOLING_FALSE);
// The merge-base size build is heavy build work and stays off the
// Node-tooling, docsite, and spec lanes.
expect(ci.jobs['size-base'].if).toContain(BROAD_ONLY);
const familyCommands = new Map([
[
'Install Playwright for theme-family contract',
Expand Down
Loading
Loading