diff --git a/packages/babel-plugin-minify-errors/index.js b/packages/babel-plugin-minify-errors/index.js index 1e79d2824..7ce9430aa 100644 --- a/packages/babel-plugin-minify-errors/index.js +++ b/packages/babel-plugin-minify-errors/index.js @@ -102,6 +102,68 @@ function handleUnminifyableError(missingError, path) { } } +/** + * @param {babel.types} t + * @param {babel.NodePath} newExpressionPath + * @param {{ detection: Options['detection']; missingError: MissingError}} param2 + * @returns {null | { messageNode: babel.types.Expression; messagePath: babel.NodePath; message: { message: string; expressions: babel.types.Expression[] } }} + */ +function findMessageNode(t, newExpressionPath, { detection, missingError }) { + if (!newExpressionPath.get('callee').isIdentifier({ name: 'Error' })) { + return null; + } + + switch (detection) { + case 'opt-in': { + if ( + !newExpressionPath.node.leadingComments?.some((comment) => + comment.value.includes(COMMENT_OPT_IN_MARKER), + ) + ) { + return null; + } + newExpressionPath.node.leadingComments = newExpressionPath.node.leadingComments.filter( + (comment) => !comment.value.includes(COMMENT_OPT_IN_MARKER), + ); + break; + } + case 'opt-out': { + if ( + newExpressionPath.node.leadingComments?.some((comment) => + comment.value.includes(COMMENT_OPT_OUT_MARKER), + ) + ) { + newExpressionPath.node.leadingComments = newExpressionPath.node.leadingComments.filter( + (comment) => !comment.value.includes(COMMENT_OPT_OUT_MARKER), + ); + return null; + } + + break; + } + default: { + throw new Error(`Unknown detection option: ${detection}`); + } + } + + const messagePath = newExpressionPath.get('arguments')[0]; + if (!messagePath) { + return null; + } + + const messageNode = messagePath.node; + if (t.isSpreadElement(messageNode) || t.isArgumentPlaceholder(messageNode)) { + handleUnminifyableError(missingError, newExpressionPath); + return null; + } + const message = extractMessage(t, messageNode); + if (!message) { + handleUnminifyableError(missingError, newExpressionPath); + return null; + } + return { messagePath, messageNode, message }; +} + /** * Transforms the error message node. * @param {babel.types} t @@ -261,59 +323,15 @@ module.exports = function plugin( name: '@mui/internal-babel-plugin-minify-errors', visitor: { NewExpression(newExpressionPath, state) { - if (!newExpressionPath.get('callee').isIdentifier({ name: 'Error' })) { - return; - } - - switch (detection) { - case 'opt-in': { - if ( - !newExpressionPath.node.leadingComments?.some((comment) => - comment.value.includes(COMMENT_OPT_IN_MARKER), - ) - ) { - return; - } - newExpressionPath.node.leadingComments = newExpressionPath.node.leadingComments.filter( - (comment) => !comment.value.includes(COMMENT_OPT_IN_MARKER), - ); - break; - } - case 'opt-out': { - if ( - newExpressionPath.node.leadingComments?.some((comment) => - comment.value.includes(COMMENT_OPT_OUT_MARKER), - ) - ) { - newExpressionPath.node.leadingComments = - newExpressionPath.node.leadingComments.filter( - (comment) => !comment.value.includes(COMMENT_OPT_OUT_MARKER), - ); - return; - } - - break; - } - default: { - throw new Error(`Unknown detection option: ${detection}`); - } - } - - const messagePath = newExpressionPath.get('arguments')[0]; - if (!messagePath) { - return; - } - - const messageNode = messagePath.node; - if (t.isSpreadElement(messageNode) || t.isArgumentPlaceholder(messageNode)) { - handleUnminifyableError(missingError, newExpressionPath); + const message = findMessageNode(t, newExpressionPath, { detection, missingError }); + if (!message) { return; } const transformedMessage = transformMessage( t, newExpressionPath, - messageNode, + message.messageNode, state, errorCodesLookup, missingError, @@ -322,7 +340,7 @@ module.exports = function plugin( ); if (transformedMessage) { - messagePath.replaceWith(transformedMessage); + message.messagePath.replaceWith(transformedMessage); } }, }, @@ -336,3 +354,7 @@ module.exports = function plugin( }, }; }; + +module.exports.findMessageNode = findMessageNode; + +exports.findMessageNode = findMessageNode; diff --git a/packages/code-infra/package.json b/packages/code-infra/package.json index 5f691c870..2c8bc77f7 100644 --- a/packages/code-infra/package.json +++ b/packages/code-infra/package.json @@ -29,6 +29,7 @@ "@argos-ci/core": "^4.1.2", "@babel/cli": "^7.28.3", "@babel/core": "^7.28.4", + "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/plugin-transform-runtime": "^7.28.3", "@babel/preset-env": "^7.28.3", diff --git a/packages/code-infra/src/cli/babel.mjs b/packages/code-infra/src/cli/babel.mjs index 1957afbf7..81105854e 100644 --- a/packages/code-infra/src/cli/babel.mjs +++ b/packages/code-infra/src/cli/babel.mjs @@ -6,6 +6,7 @@ import { globby } from 'globby'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { $ } from 'execa'; +import { BASE_IGNORES } from '../utils/build.mjs'; const TO_TRANSFORM_EXTENSIONS = ['.js', '.ts', '.tsx']; @@ -64,18 +65,6 @@ export async function cjsCopy({ from, to }) { * @property {string} [runtimeModule] - The runtime module to replace the errors with. */ -const BASE_IGNORES = [ - '**/*.test.js', - '**/*.test.ts', - '**/*.test.tsx', - '**/*.spec.js', - '**/*.spec.ts', - '**/*.spec.tsx', - '**/*.d.ts', - '**/*.test/*.*', - '**/test-cases/*.*', -]; - /** * @param {Object} options * @param {boolean} [options.verbose=false] - Whether to enable verbose logging. diff --git a/packages/code-infra/src/cli/cmdCopyFiles.mjs b/packages/code-infra/src/cli/cmdCopyFiles.mjs index ca603da9c..29bad9911 100644 --- a/packages/code-infra/src/cli/cmdCopyFiles.mjs +++ b/packages/code-infra/src/cli/cmdCopyFiles.mjs @@ -2,6 +2,7 @@ import { findWorkspaceDir } from '@pnpm/find-workspace-dir'; import { globby } from 'globby'; import fs from 'node:fs/promises'; import path from 'node:path'; +import { mapConcurrently } from '../utils/build.mjs'; /** * @typedef {Object} Args @@ -105,20 +106,13 @@ async function processGlobs({ globs, cwd, silent = true, buildDir }) { }); }); - const concurrency = filesToProcess.length > 100 ? 100 : filesToProcess.length; - const iterator = filesToProcess[Symbol.iterator](); - const workers = []; - for (let i = 0; i < concurrency; i += 1) { - workers.push( - Promise.resolve().then(async () => { - for (const file of iterator) { - // eslint-disable-next-line no-await-in-loop - await recursiveCopy({ source: file.sourcePath, target: file.targetPath, silent }); - } - }), - ); - } - await Promise.all(workers); + await mapConcurrently( + filesToProcess, + async (file) => { + await recursiveCopy({ source: file.sourcePath, target: file.targetPath, silent }); + }, + 50, + ); return filesToProcess.length; } diff --git a/packages/code-infra/src/cli/cmdExtractErrorCodes.mjs b/packages/code-infra/src/cli/cmdExtractErrorCodes.mjs new file mode 100644 index 000000000..9e49d92b0 --- /dev/null +++ b/packages/code-infra/src/cli/cmdExtractErrorCodes.mjs @@ -0,0 +1,41 @@ +/* eslint-disable no-console */ + +import { markFn, measureFn } from '../utils/build.mjs'; + +/** + * @typedef {import('../utils/extractErrorCodes.mjs').Args} Args + */ + +export default /** @type {import('yargs').CommandModule<{}, Args>} */ ({ + command: 'extract-error-codes', + describe: 'Extracts error codes from package(s).', + builder(yargs) { + return yargs + .option('errorCodesPath', { + type: 'string', + describe: 'The output path to a json file to write the extracted error codes.', + demandOption: true, + }) + .option('detection', { + type: 'string', + describe: 'The detection strategy to use when extracting error codes.', + choices: ['opt-in', 'opt-out'], + default: 'opt-in', + }) + .option('skip', { + type: 'array', + describe: 'List of package names to skip.', + default: [], + }); + }, + async handler(args) { + const commandName = /** @type {string} */ (args._[0]); + await markFn(commandName, async () => { + const module = await import('../utils/extractErrorCodes.mjs'); + await module.default(args); + }); + console.log( + `✅ Extracted error codes in ${(measureFn(commandName).duration / 1000.0).toFixed(3)}s`, + ); + }, +}); diff --git a/packages/code-infra/src/cli/cmdJsonLint.mjs b/packages/code-infra/src/cli/cmdJsonLint.mjs index df1460389..49dcae8cd 100644 --- a/packages/code-infra/src/cli/cmdJsonLint.mjs +++ b/packages/code-infra/src/cli/cmdJsonLint.mjs @@ -4,6 +4,7 @@ import chalk from 'chalk'; import fs from 'node:fs/promises'; import { globby } from 'globby'; import path from 'node:path'; +import { mapConcurrently } from '../utils/build.mjs'; /** * @typedef {Object} Args @@ -42,33 +43,25 @@ export default /** @type {import('yargs').CommandModule<{}, Args>} */ ({ followSymbolicLinks: false, }); - const fileIterator = filenames[Symbol.iterator](); - const concurrency = Math.min(20, filenames.length); let passed = true; - const workers = []; - for (let i = 0; i < concurrency; i += 1) { - // eslint-disable-next-line @typescript-eslint/no-loop-func - const worker = Promise.resolve().then(async () => { - for (const filename of fileIterator) { - // eslint-disable-next-line no-await-in-loop - const content = await fs.readFile(path.join(cwd, filename), { encoding: 'utf8' }); - try { - JSON.parse(content); - if (!args.silent) { - // eslint-disable-next-line no-console - console.log(passMessage(filename)); - } - } catch (error) { - passed = false; - console.error(failMessage(`Error parsing ${filename}:\n\n${String(error)}`)); + await mapConcurrently( + filenames, + async (filename) => { + const content = await fs.readFile(path.join(cwd, filename), { encoding: 'utf8' }); + try { + JSON.parse(content); + if (!args.silent) { + // eslint-disable-next-line no-console + console.log(passMessage(filename)); } + } catch (error) { + passed = false; + console.error(failMessage(`Error parsing ${filename}:\n\n${String(error)}`)); } - }); - workers.push(worker); - } - - await Promise.allSettled(workers); + }, + 20, + ); if (!passed) { throw new Error('❌ At least one file did not pass. Check the console output'); } diff --git a/packages/code-infra/src/cli/index.mjs b/packages/code-infra/src/cli/index.mjs index 3b8b225a0..f41d62802 100644 --- a/packages/code-infra/src/cli/index.mjs +++ b/packages/code-infra/src/cli/index.mjs @@ -5,6 +5,7 @@ import { hideBin } from 'yargs/helpers'; import cmdArgosPush from './cmdArgosPush.mjs'; import cmdBuild from './cmdBuild.mjs'; import cmdCopyFiles from './cmdCopyFiles.mjs'; +import cmdExtractErrorCodes from './cmdExtractErrorCodes.mjs'; import cmdJsonLint from './cmdJsonLint.mjs'; import cmdListWorkspaces from './cmdListWorkspaces.mjs'; import cmdPublish from './cmdPublish.mjs'; @@ -15,14 +16,16 @@ const pkgJson = createRequire(import.meta.url)('../../package.json'); yargs() .scriptName('code-infra') + .usage('$0 [args]') + .command(cmdArgosPush) + .command(cmdBuild) + .command(cmdCopyFiles) + .command(cmdExtractErrorCodes) + .command(cmdJsonLint) + .command(cmdListWorkspaces) .command(cmdPublish) .command(cmdPublishCanary) - .command(cmdListWorkspaces) - .command(cmdJsonLint) - .command(cmdArgosPush) .command(cmdSetVersionOverrides) - .command(cmdCopyFiles) - .command(cmdBuild) .demandCommand(1, 'You need at least one command before moving on') .strict() .help() diff --git a/packages/code-infra/src/untyped-plugins.d.ts b/packages/code-infra/src/untyped-plugins.d.ts index 3d6917f2a..3cdfb296f 100644 --- a/packages/code-infra/src/untyped-plugins.d.ts +++ b/packages/code-infra/src/untyped-plugins.d.ts @@ -102,6 +102,13 @@ declare module '@babel/plugin-transform-runtime' { export default plugin; } +declare module '@babel/plugin-syntax-jsx' { + import type { PluginItem } from '@babel/core'; + + declare const plugin: PluginItem; + export default plugin; +} + declare module '@babel/plugin-syntax-typescript' { import type { PluginItem } from '@babel/core'; diff --git a/packages/code-infra/src/utils/build.mjs b/packages/code-infra/src/utils/build.mjs index 0e3927fc1..7b1b3285e 100644 --- a/packages/code-infra/src/utils/build.mjs +++ b/packages/code-infra/src/utils/build.mjs @@ -68,3 +68,82 @@ export function validatePkgJson(packageJson, options = {}) { throw error; } } + +/** + * Marks the start and end of a function execution for performance measurement. + * Uses the Performance API to create marks and measure the duration. + * @function + * @template {() => Promise} F + * @param {string} label + * @param {() => ReturnType} fn + * @returns {Promise>} + */ +export async function markFn(label, fn) { + const startMark = `${label}-start`; + const endMark = `${label}-end`; + performance.mark(startMark); + const result = await fn(); + performance.mark(endMark); + performance.measure(label, startMark, endMark); + return result; +} + +/** + * @param {string} label + */ +export function measureFn(label) { + const startMark = `${label}-start`; + const endMark = `${label}-end`; + return performance.measure(label, startMark, endMark); +} + +export const BASE_IGNORES = [ + '**/*.test.js', + '**/*.test.ts', + '**/*.test.tsx', + '**/*.spec.js', + '**/*.spec.ts', + '**/*.spec.tsx', + '**/*.d.ts', + '**/*.test/*.*', + '**/test-cases/*.*', +]; + +/** + * A utility to map a function over an array of items in a worker pool. + * + * This function will create a pool of workers and distribute the items to be processed among them. + * Each worker will process items sequentially, but multiple workers will run in parallel. + * + * @function + * @template T + * @template R + * @param {T[]} items + * @param {(item: T) => Promise} mapper + * @param {number} concurrency + * @returns {Promise<(R|Error)[]>} + */ +export async function mapConcurrently(items, mapper, concurrency) { + if (!items.length) { + return Promise.resolve([]); // nothing to do + } + const itemIterator = items.entries(); + const count = Math.min(concurrency, items.length); + const workers = []; + /** + * @type {(R|Error)[]} + */ + const results = new Array(items.length); + for (let i = 0; i < count; i += 1) { + const worker = Promise.resolve().then(async () => { + for (const [index, item] of itemIterator) { + // eslint-disable-next-line no-await-in-loop + const res = await mapper(item); + results[index] = res; + } + }); + workers.push(worker); + } + await Promise.all(workers); + return results; +} diff --git a/packages/code-infra/src/utils/extractErrorCodes.mjs b/packages/code-infra/src/utils/extractErrorCodes.mjs new file mode 100644 index 000000000..8af94255b --- /dev/null +++ b/packages/code-infra/src/utils/extractErrorCodes.mjs @@ -0,0 +1,168 @@ +/* eslint-disable no-console */ +import { types as babelTypes, parseAsync, traverse } from '@babel/core'; +import babelSyntaxJsx from '@babel/plugin-syntax-jsx'; +import babelSyntaxTypescript from '@babel/plugin-syntax-typescript'; +import { findMessageNode } from '@mui/internal-babel-plugin-minify-errors'; +import { globby } from 'globby'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import { getWorkspacePackages } from '../cli/pnpm.mjs'; +import { BASE_IGNORES, mapConcurrently } from './build.mjs'; + +/** + * @typedef {Object} Args + * @property {string} errorCodesPath - The output path to write the extracted error codes. + * @property {string[]} [skip=[]] - List of package names to skip. By default, all workspace packages are considered. + * @property {import('@mui/internal-babel-plugin-minify-errors').Options['detection']} [detection='opt-in'] - The detection strategy to use when extracting error codes. + */ + +/** + * Gets all relevant files for a package to parse. + * + * @param {import('../cli/pnpm.mjs').PublicPackage} pkg + * @returns {Promise} An array of file paths. + */ +async function getFilesForPackage(pkg) { + const srcPath = path.join(pkg.path, 'src'); + const srcPathExists = await fs + .stat(srcPath) + .then((stat) => stat.isDirectory()) + .catch(() => false); + // Implementation to extract error codes from all files in the directory + const cwd = srcPathExists ? srcPath : pkg.path; + const files = await globby('**/*.{js,ts,jsx,tsx,cjs,mjs,cts}', { + ignore: [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + 'scripts', + '**/__{tests,fixtures,mock,mocks}__/**', + ...BASE_IGNORES, + ], + cwd, + }); + return files.map((file) => path.join(cwd, file)); +} + +/** + * Extracts error codes from all files in a directory. + * @param {string[]} files + * @param {Set} errors + * @param {import('@mui/internal-babel-plugin-minify-errors').Options['detection']} [detection='opt-in'] + */ +async function extractErrorCodesForWorkspace(files, errors, detection = 'opt-in') { + await mapConcurrently( + files, + async (fullPath) => { + const code = await fs.readFile(fullPath, 'utf8'); + const ast = await parseAsync(code, { + filename: fullPath, + sourceType: 'module', + plugins: [[babelSyntaxTypescript, { isTSX: true }], [babelSyntaxJsx]], + configFile: false, + babelrc: false, + browserslistConfigFile: false, + code: false, + }); + if (!ast) { + throw new Error(`Failed to parse ${fullPath}`); + } + traverse(ast, { + NewExpression(newExpressionPath) { + const { message } = + findMessageNode(babelTypes, newExpressionPath, { + detection, + missingError: 'annotate', + }) ?? {}; + if (message) { + errors.add(message.message); + } + }, + }); + }, + 30, + ); +} + +/** + * Extracts error codes from all workspace packages. + * @param {Args} args + */ +export default async function extractErrorCodes(args) { + /** + * @type {Set} + */ + const errors = new Set(); + + // Find relevant files + + const basePackages = await getWorkspacePackages({ + publicOnly: true, + }); + const { skip: skipPackages = [], errorCodesPath, detection = 'opt-in' } = args; + const packages = basePackages.filter( + (pkg) => + // Ignore obvious packages that do not have user-facing errors + !pkg.name.startsWith('@mui/internal-') && + !pkg.name.startsWith('@mui-internal/') && + !skipPackages.includes(pkg.name), + ); + const files = await Promise.all(packages.map((pkg) => getFilesForPackage(pkg))); + packages.forEach((pkg, index) => { + console.log( + `🔍 ${pkg.name}: Found ${files[index].length} file${files[index].length > 1 ? 's' : ''}`, + ); + }); + + // Extract error codes from said files. + const filesToProcess = files.flat(); + console.log(`🔍 Extracting error codes from ${filesToProcess.length} files...`); + await extractErrorCodesForWorkspace(filesToProcess, errors, detection); + + // Write error codes to the specified file. + const errorCodeFilePath = path.resolve(errorCodesPath); + const fileExists = await fs + .stat(errorCodeFilePath) + .then((stat) => stat.isFile()) + .catch((ex) => { + if (ex.code === 'ENOENT') { + return false; + } + return new Error(ex.message); + }); + + if (fileExists instanceof Error) { + throw fileExists; + } + /** + * @type {Record} + */ + const existingErrorCodes = + fileExists === true ? JSON.parse(await fs.readFile(errorCodeFilePath, 'utf-8')) : {}; + const inverseLookupCode = new Map( + Object.entries(existingErrorCodes).map(([key, value]) => [value, Number(key)]), + ); + const originalErrorCount = inverseLookupCode.size; + Array.from(errors).forEach((error) => { + if (!inverseLookupCode.has(error)) { + inverseLookupCode.set(error, inverseLookupCode.size + 1); + } + }); + const finalErrorCodes = Array.from(inverseLookupCode.entries()).reduce((acc, [message, code]) => { + acc[code] = message; + return acc; + }, /** @type {Record} */ ({})); + if (!fileExists) { + await fs.mkdir(path.dirname(errorCodeFilePath), { recursive: true }); + } + const newErrorCount = inverseLookupCode.size - originalErrorCount; + if (newErrorCount === 0) { + console.log(`✅ No new error codes found.`); + } else { + console.log( + `📝 Wrote ${newErrorCount} new error code${newErrorCount > 1 ? 's' : ''} to "${errorCodesPath}"`, + ); + await fs.writeFile(errorCodeFilePath, `${JSON.stringify(finalErrorCodes, null, 2)}\n`); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f78d90b74..93558764e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -344,6 +344,9 @@ importers: '@babel/core': specifier: ^7.28.4 version: 7.28.4 + '@babel/plugin-syntax-jsx': + specifier: ^7.27.1 + version: 7.27.1(@babel/core@7.28.4) '@babel/plugin-syntax-typescript': specifier: ^7.27.1 version: 7.27.1(@babel/core@7.28.4)