diff --git a/common/changes/@rushstack/hashed-folder-copy-plugin/main_2026-06-26-00-18-27.json b/common/changes/@rushstack/hashed-folder-copy-plugin/main_2026-06-26-00-18-27.json new file mode 100644 index 00000000000..e8c42a34e96 --- /dev/null +++ b/common/changes/@rushstack/hashed-folder-copy-plugin/main_2026-06-26-00-18-27.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/hashed-folder-copy-plugin", + "comment": "", + "type": "none" + } + ], + "packageName": "@rushstack/hashed-folder-copy-plugin" +} \ No newline at end of file diff --git a/common/changes/@rushstack/webpack-plugin-utilities/evaluate-constant-estree-expression_2026-06-25-00-00.json b/common/changes/@rushstack/webpack-plugin-utilities/evaluate-constant-estree-expression_2026-06-25-00-00.json new file mode 100644 index 00000000000..130f7c03976 --- /dev/null +++ b/common/changes/@rushstack/webpack-plugin-utilities/evaluate-constant-estree-expression_2026-06-25-00-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Add `evaluateConstantEstreeExpression` for statically evaluating constant ESTree expression nodes.", + "type": "minor", + "packageName": "@rushstack/webpack-plugin-utilities" + } + ], + "packageName": "@rushstack/webpack-plugin-utilities", + "email": "iclanton@users.noreply.github.com" +} diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index c7f7ad87712..a9d0518ccff 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -5407,6 +5407,9 @@ importers: '@rushstack/node-core-library': specifier: workspace:* version: link:../../libraries/node-core-library + '@rushstack/webpack-plugin-utilities': + specifier: workspace:* + version: link:../webpack-plugin-utilities fast-glob: specifier: ~3.3.1 version: 3.3.3 @@ -5560,6 +5563,9 @@ importers: ../../../webpack/webpack-plugin-utilities: dependencies: + '@types/estree': + specifier: 1.0.8 + version: 1.0.8 memfs: specifier: 4.12.0 version: 4.12.0 diff --git a/common/reviews/api/webpack-plugin-utilities.api.md b/common/reviews/api/webpack-plugin-utilities.api.md index 3b2bf0ac56f..18f18316f0b 100644 --- a/common/reviews/api/webpack-plugin-utilities.api.md +++ b/common/reviews/api/webpack-plugin-utilities.api.md @@ -5,11 +5,16 @@ ```ts import type { Configuration } from 'webpack'; +import type { Expression } from 'estree'; import { IFs } from 'memfs'; import type { MultiStats } from 'webpack'; +import type { SpreadElement } from 'estree'; import type { Stats } from 'webpack'; import type * as Webpack from 'webpack'; +// @beta +export function evaluateConstantEstreeExpression(node: Expression | SpreadElement): TNode; + // @public function getTestingWebpackCompilerAsync(entry: string, additionalConfig?: Configuration, memFs?: IFs): Promise<(Stats | MultiStats) | undefined>; diff --git a/webpack/hashed-folder-copy-plugin/package.json b/webpack/hashed-folder-copy-plugin/package.json index de5f137e915..418d8b746c5 100644 --- a/webpack/hashed-folder-copy-plugin/package.json +++ b/webpack/hashed-folder-copy-plugin/package.json @@ -52,6 +52,7 @@ }, "dependencies": { "@rushstack/node-core-library": "workspace:*", + "@rushstack/webpack-plugin-utilities": "workspace:*", "fast-glob": "~3.3.1" }, "devDependencies": { diff --git a/webpack/hashed-folder-copy-plugin/src/HashedFolderCopyPlugin.ts b/webpack/hashed-folder-copy-plugin/src/HashedFolderCopyPlugin.ts index c69bf568dcd..20383845503 100644 --- a/webpack/hashed-folder-copy-plugin/src/HashedFolderCopyPlugin.ts +++ b/webpack/hashed-folder-copy-plugin/src/HashedFolderCopyPlugin.ts @@ -6,6 +6,7 @@ import type webpack from 'webpack'; import type glob from 'fast-glob'; import { Async } from '@rushstack/node-core-library'; +import { evaluateConstantEstreeExpression } from '@rushstack/webpack-plugin-utilities'; import { type IHashedFolderDependency, @@ -25,16 +26,6 @@ const PLUGIN_NAME: 'hashed-folder-copy-plugin' = 'hashed-folder-copy-plugin'; const EXPRESSION_NAME: 'requireFolder' = 'requireFolder'; -interface IAcornNode { - computed: boolean | undefined; - elements: IAcornNode[]; - key: IAcornNode | undefined; - name: string | undefined; - properties: IAcornNode[] | undefined; - type: 'Literal' | 'ObjectExpression' | 'Identifier' | 'ArrayExpression' | unknown; - value: TExpression; -} - export function renderError(errorMessage: string): string { return `(function () { throw new Error(${JSON.stringify(errorMessage)}); })()`; } @@ -92,10 +83,9 @@ export class HashedFolderCopyPlugin implements webpack.WebpackPluginInstance { if (expression.arguments.length !== 1) { errorMessage = `Exactly one argument is required to be passed to "${EXPRESSION_NAME}"`; } else { - const argument: IAcornNode = expression - .arguments[0] as IAcornNode; + const argument: Expression = expression.arguments[0] as Expression; try { - requireFolderOptions = this._evaluateAcornNode(argument) as IRequireFolderOptions; + requireFolderOptions = evaluateConstantEstreeExpression(argument); } catch (e) { errorMessage = (e as Error).message; } @@ -164,37 +154,4 @@ export class HashedFolderCopyPlugin implements webpack.WebpackPluginInstance { } ); } - - private _evaluateAcornNode(node: IAcornNode): unknown { - switch (node.type) { - case 'Literal': { - return node.value; - } - - case 'ObjectExpression': { - const result: Record = {}; - - for (const property of node.properties!) { - const keyNode: IAcornNode = property.key!; - if (keyNode.type !== 'Identifier' || keyNode.computed) { - throw new Error('Property keys must be non-computed identifiers'); - } - - const key: string = keyNode.name!; - const value: unknown = this._evaluateAcornNode(property.value as IAcornNode); - result[key] = value; - } - - return result; - } - - case 'ArrayExpression': { - return node.elements.map((element) => this._evaluateAcornNode(element)); - } - - default: { - throw new Error(`Unsupported node type: "${node.type}"`); - } - } - } } diff --git a/webpack/webpack-plugin-utilities/package.json b/webpack/webpack-plugin-utilities/package.json index 47d8d00e18c..6b57f1ebfd8 100644 --- a/webpack/webpack-plugin-utilities/package.json +++ b/webpack/webpack-plugin-utilities/package.json @@ -35,11 +35,14 @@ }, "scripts": { "build": "heft build --clean", - "_phase:build": "heft run --only build -- --clean" + "test": "heft run --only test", + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "heft run --only test -- --clean" }, "dependencies": { - "webpack-merge": "~5.8.0", - "memfs": "4.12.0" + "@types/estree": "1.0.8", + "memfs": "4.12.0", + "webpack-merge": "~5.8.0" }, "peerDependencies": { "@types/webpack": "^4.39.8", @@ -55,9 +58,9 @@ }, "devDependencies": { "@rushstack/heft": "workspace:*", + "@types/tapable": "1.0.6", "eslint": "~9.37.0", "local-node-rig": "workspace:*", - "@types/tapable": "1.0.6", "webpack": "~5.105.2" }, "sideEffects": false diff --git a/webpack/webpack-plugin-utilities/src/evaluateConstantEstreeExpression.ts b/webpack/webpack-plugin-utilities/src/evaluateConstantEstreeExpression.ts new file mode 100644 index 00000000000..44795893abf --- /dev/null +++ b/webpack/webpack-plugin-utilities/src/evaluateConstantEstreeExpression.ts @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { Expression, PrivateIdentifier, SpreadElement } from 'estree'; + +/** + * Statically evaluates an ESTree (acorn) expression node into its corresponding + * runtime JavaScript value. + * + * @remarks + * This is intended to be used inside webpack plugins and loaders that hook into + * the parser (for example `parser.hooks.call`) and need to read the literal + * arguments passed to a function call at build time, without actually executing + * the user's code. A common use case is extracting a plain options object that + * was passed to a custom `require()`-style expression. + * + * Only a small subset of expression types is supported, namely those needed to + * express JSON-like constant values: + * + * - `Literal` (strings, numbers, booleans, `null`, etc.) + * - `UnaryExpression` (the `-`, `+`, `!`, and `~` operators applied to a constant + * argument, e.g. the `-1` in `{ count: -1 }`) + * - `TemplateLiteral` (only when it has no `${...}` substitutions) + * - `ObjectExpression` (with non-computed identifier keys) + * - `ArrayExpression` (including sparse holes, which evaluate to `null`) + * + * @example + * ```ts + * // Given source: requireFolder({ outputFolder: 'assets', sources: [] }) + * const options: IRequireFolderOptions = evaluateConstantEstreeExpression(callExpression.arguments[0]); + * ``` + * + * @remarks Limitations + * Because the node is evaluated statically rather than executed, anything that + * is not a compile-time constant is unsupported and will cause an `Error` to be + * thrown. This includes: + * + * - Identifiers and variable references (e.g. `someVariable`) + * - Computed property keys (e.g. `{ [key]: value }`) + * - Spread elements in object expressions (e.g. `{ ...other }`) + * - Function calls, template literals, and any other expression type not listed above + * + * @param node - The ESTree expression node to evaluate. + * @returns The evaluated value, cast to the caller-specified type `TNode`. Note + * that the cast is unchecked; the caller is responsible for validating that the + * returned shape matches `TNode`. + * @throws An `Error` if the node (or any nested node) uses an unsupported + * expression type or syntax. + * @beta + */ +export function evaluateConstantEstreeExpression(node: Expression | SpreadElement): TNode { + switch (node.type) { + case 'Literal': { + return node.value as TNode; + } + + case 'UnaryExpression': { + const argumentValue: unknown = evaluateConstantEstreeExpression(node.argument); + switch (node.operator) { + case '-': { + return -(argumentValue as number) as TNode; + } + case '+': { + return +(argumentValue as number) as TNode; + } + case '!': { + return !argumentValue as TNode; + } + case '~': { + return ~(argumentValue as number) as TNode; + } + default: { + throw new Error(`Unsupported unary operator: "${node.operator}"`); + } + } + } + + case 'TemplateLiteral': { + if (node.expressions.length > 0) { + throw new Error('Template literals with substitutions are not supported'); + } + + return node.quasis[0].value.cooked as TNode; + } + + case 'ObjectExpression': { + const result: Record = {}; + + for (const property of node.properties) { + if (property.type === 'SpreadElement') { + throw new Error('Spread elements are not supported in object expressions'); + } + + const keyNode: Expression | PrivateIdentifier = property.key; + if (keyNode.type !== 'Identifier' || property.computed) { + throw new Error('Property keys must be non-computed identifiers'); + } + + const key: string = keyNode.name; + const value: unknown = evaluateConstantEstreeExpression(property.value as Expression); + result[key] = value; + } + + return result as TNode; + } + + case 'ArrayExpression': { + return node.elements.map((element) => + element === null ? null : evaluateConstantEstreeExpression(element) + ) as TNode; + } + + default: { + throw new Error(`Unsupported node type: "${node.type}"`); + } + } +} diff --git a/webpack/webpack-plugin-utilities/src/index.ts b/webpack/webpack-plugin-utilities/src/index.ts index 75a14e2aa39..258e82fe66c 100644 --- a/webpack/webpack-plugin-utilities/src/index.ts +++ b/webpack/webpack-plugin-utilities/src/index.ts @@ -10,3 +10,5 @@ import * as VersionDetection from './DetectWebpackVersion'; import * as Testing from './Testing'; export { VersionDetection, Testing }; + +export { evaluateConstantEstreeExpression } from './evaluateConstantEstreeExpression'; diff --git a/webpack/webpack-plugin-utilities/src/test/evaluateConstantEstreeExpression.test.ts b/webpack/webpack-plugin-utilities/src/test/evaluateConstantEstreeExpression.test.ts new file mode 100644 index 00000000000..4f7f460b383 --- /dev/null +++ b/webpack/webpack-plugin-utilities/src/test/evaluateConstantEstreeExpression.test.ts @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { + ArrayExpression, + Expression, + Identifier, + ObjectExpression, + Property, + SimpleLiteral, + SpreadElement, + TemplateLiteral, + UnaryExpression, + UnaryOperator +} from 'estree'; + +import { evaluateConstantEstreeExpression } from '../evaluateConstantEstreeExpression'; + +// Minimal typed builders for the subset of ESTree nodes used by these tests. +// They intentionally only populate the fields that `evaluateConstantEstreeExpression` reads. + +function literal(value: SimpleLiteral['value']): SimpleLiteral { + return { type: 'Literal', value }; +} + +function identifier(name: string): Identifier { + return { type: 'Identifier', name }; +} + +function unary(operator: UnaryOperator, argument: Expression): UnaryExpression { + return { type: 'UnaryExpression', operator, prefix: true, argument }; +} + +function templateLiteral(cooked: string, expressions: Expression[] = []): TemplateLiteral { + return { + type: 'TemplateLiteral', + expressions, + quasis: [ + { + type: 'TemplateElement', + tail: true, + value: { cooked, raw: cooked } + } + ] + }; +} + +function array(elements: ArrayExpression['elements']): ArrayExpression { + return { type: 'ArrayExpression', elements }; +} + +function property(key: Expression, value: Expression, options: { computed?: boolean } = {}): Property { + return { + type: 'Property', + key, + value, + kind: 'init', + method: false, + shorthand: false, + computed: options.computed ?? false + }; +} + +function object(properties: Array): ObjectExpression { + return { type: 'ObjectExpression', properties }; +} + +describe(evaluateConstantEstreeExpression.name, () => { + describe('Literal', () => { + it('evaluates string literals', () => { + expect(evaluateConstantEstreeExpression(literal('hello'))).toBe('hello'); + }); + + it('evaluates number literals', () => { + expect(evaluateConstantEstreeExpression(literal(42))).toBe(42); + }); + + it('evaluates boolean literals', () => { + expect(evaluateConstantEstreeExpression(literal(true))).toBe(true); + expect(evaluateConstantEstreeExpression(literal(false))).toBe(false); + }); + + it('evaluates null literals', () => { + expect(evaluateConstantEstreeExpression(literal(null))).toBeNull(); + }); + }); + + describe('UnaryExpression', () => { + it('evaluates negated numbers', () => { + expect(evaluateConstantEstreeExpression(unary('-', literal(1)))).toBe(-1); + }); + + it('evaluates the unary plus operator', () => { + expect(evaluateConstantEstreeExpression(unary('+', literal(5)))).toBe(5); + }); + + it('evaluates the logical not operator', () => { + expect(evaluateConstantEstreeExpression(unary('!', literal(false)))).toBe(true); + expect(evaluateConstantEstreeExpression(unary('!', literal(0)))).toBe(true); + expect(evaluateConstantEstreeExpression(unary('!', literal('')))).toBe(true); + }); + + it('evaluates the bitwise not operator', () => { + expect(evaluateConstantEstreeExpression(unary('~', literal(0)))).toBe(-1); + }); + + it('throws for unsupported unary operators', () => { + expect(() => evaluateConstantEstreeExpression(unary('typeof', literal('x')))).toThrow( + 'Unsupported unary operator: "typeof"' + ); + }); + }); + + describe('TemplateLiteral', () => { + it('evaluates a template literal with no substitutions', () => { + expect(evaluateConstantEstreeExpression(templateLiteral('hello'))).toBe('hello'); + }); + + it('throws for template literals with substitutions', () => { + const node: TemplateLiteral = templateLiteral('hello', [literal('world')]); + expect(() => evaluateConstantEstreeExpression(node)).toThrow( + 'Template literals with substitutions are not supported' + ); + }); + }); + + describe('ArrayExpression', () => { + it('evaluates an array of literals', () => { + const node: ArrayExpression = array([literal(1), literal('two'), literal(true)]); + expect(evaluateConstantEstreeExpression(node)).toEqual([1, 'two', true]); + }); + + it('evaluates an empty array', () => { + expect(evaluateConstantEstreeExpression(array([]))).toEqual([]); + }); + + it('evaluates sparse holes as null', () => { + const node: ArrayExpression = array([literal(1), null, literal(3)]); + expect(evaluateConstantEstreeExpression(node)).toEqual([1, null, 3]); + }); + + it('evaluates nested arrays', () => { + const node: ArrayExpression = array([array([literal(1)]), array([literal(2)])]); + expect(evaluateConstantEstreeExpression(node)).toEqual([[1], [2]]); + }); + }); + + describe('ObjectExpression', () => { + it('evaluates an object with identifier keys', () => { + const node: ObjectExpression = object([ + property(identifier('outputFolder'), literal('assets')), + property(identifier('count'), unary('-', literal(1))) + ]); + expect(evaluateConstantEstreeExpression(node)).toEqual({ outputFolder: 'assets', count: -1 }); + }); + + it('evaluates an empty object', () => { + expect(evaluateConstantEstreeExpression(object([]))).toEqual({}); + }); + + it('evaluates nested objects and arrays', () => { + const node: ObjectExpression = object([ + property( + identifier('sources'), + array([object([property(identifier('globsBase'), literal('./assets'))])]) + ) + ]); + expect(evaluateConstantEstreeExpression(node)).toEqual({ + sources: [{ globsBase: './assets' }] + }); + }); + + it('throws for computed keys', () => { + const node: ObjectExpression = object([ + property(identifier('key'), literal('value'), { computed: true }) + ]); + expect(() => evaluateConstantEstreeExpression(node)).toThrow( + 'Property keys must be non-computed identifiers' + ); + }); + + it('throws for non-identifier keys', () => { + const node: ObjectExpression = object([property(literal('key'), literal('value'))]); + expect(() => evaluateConstantEstreeExpression(node)).toThrow( + 'Property keys must be non-computed identifiers' + ); + }); + + it('throws for spread elements', () => { + const node: ObjectExpression = object([{ type: 'SpreadElement', argument: identifier('other') }]); + expect(() => evaluateConstantEstreeExpression(node)).toThrow( + 'Spread elements are not supported in object expressions' + ); + }); + }); + + describe('unsupported nodes', () => { + it('throws for identifiers', () => { + expect(() => evaluateConstantEstreeExpression(identifier('someVariable'))).toThrow( + 'Unsupported node type: "Identifier"' + ); + }); + + it('throws for call expressions', () => { + const node: Expression = { + type: 'CallExpression', + callee: identifier('fn'), + arguments: [], + optional: false + }; + expect(() => evaluateConstantEstreeExpression(node)).toThrow('Unsupported node type: "CallExpression"'); + }); + }); +});