From e1157546581876c1ff842ab33db6464179a06a04 Mon Sep 17 00:00:00 2001
From: Dylan Staley <88163+dstaley@users.noreply.github.com>
Date: Sat, 4 Oct 2025 13:42:34 -0500
Subject: [PATCH 1/3] feat(repo): Add eslint-plugin-tree-shaking
---
eslint.config.mjs | 9 +
package.json | 1 +
packages/eslint-plugin-tree-shaking/LICENSE | 22 +
packages/eslint-plugin-tree-shaking/README.md | 18 +
.../eslint-plugin-tree-shaking/package.json | 20 +
.../eslint-plugin-tree-shaking/src/index.ts | 21 +
.../no-side-effects-in-initialization.ts | 1108 +++++++++++++++++
.../src/utils/helpers.ts | 134 ++
.../src/utils/pure-functions.ts | 139 +++
.../src/utils/value.ts | 31 +
.../eslint-plugin-tree-shaking/tsconfig.json | 10 +
pnpm-lock.yaml | 24 +-
turbo.json | 2 +-
13 files changed, 1537 insertions(+), 2 deletions(-)
create mode 100644 packages/eslint-plugin-tree-shaking/LICENSE
create mode 100644 packages/eslint-plugin-tree-shaking/README.md
create mode 100644 packages/eslint-plugin-tree-shaking/package.json
create mode 100644 packages/eslint-plugin-tree-shaking/src/index.ts
create mode 100644 packages/eslint-plugin-tree-shaking/src/rules/no-side-effects-in-initialization.ts
create mode 100644 packages/eslint-plugin-tree-shaking/src/utils/helpers.ts
create mode 100644 packages/eslint-plugin-tree-shaking/src/utils/pure-functions.ts
create mode 100644 packages/eslint-plugin-tree-shaking/src/utils/value.ts
create mode 100644 packages/eslint-plugin-tree-shaking/tsconfig.json
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 8ad097edd9e..227018db7d1 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -1,6 +1,7 @@
import eslint from '@eslint/js';
import configPrettier from 'eslint-config-prettier';
import configTurbo from 'eslint-config-turbo/flat';
+import pluginTreeShaking from '@clerk/eslint-plugin-tree-shaking';
import pluginImport from 'eslint-plugin-import';
import pluginJest from 'eslint-plugin-jest';
import pluginJsDoc from 'eslint-plugin-jsdoc';
@@ -406,6 +407,14 @@ export default tseslint.config([
'@typescript-eslint/no-floating-promises': 'warn',
},
},
+ {
+ name: 'packages/react',
+ files: ['packages/react/src/**/*'],
+ ...pluginTreeShaking.configs.recommended,
+ rules: {
+ 'tree-shaking/no-side-effects-in-initialization': 'warn',
+ },
+ },
{
name: 'repo/integration',
...pluginPlaywright.configs['flat/recommended'],
diff --git a/package.json b/package.json
index 1320877a2a8..71ad66ef61a 100644
--- a/package.json
+++ b/package.json
@@ -69,6 +69,7 @@
"@changesets/cli": "^2.29.4",
"@changesets/get-github-info": "^0.6.0",
"@clerk/backend": "workspace:*",
+ "@clerk/eslint-plugin-tree-shaking": "workspace:*",
"@clerk/shared": "workspace:*",
"@clerk/testing": "workspace:*",
"@commitlint/cli": "^19.8.0",
diff --git a/packages/eslint-plugin-tree-shaking/LICENSE b/packages/eslint-plugin-tree-shaking/LICENSE
new file mode 100644
index 00000000000..630f0d68cf0
--- /dev/null
+++ b/packages/eslint-plugin-tree-shaking/LICENSE
@@ -0,0 +1,22 @@
+MIT License
+
+Copyright (c) 2022 Clerk, Inc.
+Copyright (c) 2016 Lukas Taegert
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/eslint-plugin-tree-shaking/README.md b/packages/eslint-plugin-tree-shaking/README.md
new file mode 100644
index 00000000000..7cd774a8e73
--- /dev/null
+++ b/packages/eslint-plugin-tree-shaking/README.md
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
@clerk/eslint-plugin-tree-shaking
+
+
+---
+
+## License and Credits
+
+This project is licensed under the **MIT license**, and is a fork of the [`eslint-plugin-tree-shaking`](https://github.com/lukastaegert/eslint-plugin-tree-shaking) library written by Lukas Taegert.
+
+See [LICENSE](https://github.com/clerk/javascript/blob/main/packages/eslint-plugin-tree-shaking/LICENSE) for more information.
diff --git a/packages/eslint-plugin-tree-shaking/package.json b/packages/eslint-plugin-tree-shaking/package.json
new file mode 100644
index 00000000000..0b80dd2e3ce
--- /dev/null
+++ b/packages/eslint-plugin-tree-shaking/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "@clerk/eslint-plugin-tree-shaking",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "scripts": {
+ "build": "tsc"
+ },
+ "devDependencies": {
+ "@types/estree": "^1.0.8",
+ "@types/estree-jsx": "^1.0.5",
+ "eslint": "9.31.0"
+ }
+}
diff --git a/packages/eslint-plugin-tree-shaking/src/index.ts b/packages/eslint-plugin-tree-shaking/src/index.ts
new file mode 100644
index 00000000000..1c0e64e02b5
--- /dev/null
+++ b/packages/eslint-plugin-tree-shaking/src/index.ts
@@ -0,0 +1,21 @@
+import type { ESLint } from 'eslint';
+import { noSideEffectsInInitialization } from './rules/no-side-effects-in-initialization.js';
+
+const plugin: ESLint.Plugin = {
+ meta: {
+ name: 'tree-shaking',
+ },
+ configs: {},
+ rules: {
+ 'no-side-effects-in-initialization': noSideEffectsInInitialization,
+ },
+};
+
+Object.assign((plugin.configs ??= {}), {
+ recommended: {
+ plugins: { 'tree-shaking': plugin },
+ rules: { 'tree-shaking/no-side-effects-in-initialization': 'error' },
+ },
+});
+
+export default plugin;
diff --git a/packages/eslint-plugin-tree-shaking/src/rules/no-side-effects-in-initialization.ts b/packages/eslint-plugin-tree-shaking/src/rules/no-side-effects-in-initialization.ts
new file mode 100644
index 00000000000..62ede7eca75
--- /dev/null
+++ b/packages/eslint-plugin-tree-shaking/src/rules/no-side-effects-in-initialization.ts
@@ -0,0 +1,1108 @@
+// cf. https://astexplorer.net
+// https://github.com/estree/estree
+// https://github.com/facebook/jsx/blob/master/AST.md
+// http://mazurov.github.io/escope-demo
+// https://npmdoc.github.io/node-npmdoc-escope/build/apidoc.html
+
+///
+import { Rule, Scope } from 'eslint';
+import { Program, Node, BinaryOperator, LogicalOperator, UnaryOperator } from 'estree';
+import {
+ getChildScopeForNodeIfExists,
+ isLocalVariableAWhitelistedModule,
+ getLocalVariable,
+ getRootNode,
+ getTreeShakingComments,
+ isFirstLetterUpperCase,
+ isPureFunction,
+ isFunctionSideEffectFree,
+ noEffects,
+ hasPureNotation,
+} from '../utils/helpers.js';
+import { Value } from '../utils/value.js';
+
+type ListenerArgs = [
+ node: T extends { type: K } ? T : never,
+ scope: Scope.Scope,
+ options: Rule.RuleContext['options'],
+];
+
+type Listener = (...args: ListenerArgs) => void;
+
+type ListenerWithValue = (...args: ListenerArgs) => Value;
+
+type ListenerMap = {
+ [K in T['type']]: {
+ reportEffects?: Listener;
+ reportEffectsWhenAssigned?: Listener;
+ reportEffectsWhenCalled?: Listener;
+ reportEffectsWhenMutated?: Listener;
+ getValueAndReportEffects?: ListenerWithValue;
+ };
+};
+
+const COMMENT_NO_SIDE_EFFECT_WHEN_CALLED = 'no-side-effects-when-called';
+
+const getUnknownSideEffectError = (subject: string) => `Cannot determine side-effects of ${subject}`;
+
+const getAssignmentError = (target: string) => getUnknownSideEffectError(`assignment to ${target}`);
+const getCallError = (target: string) => getUnknownSideEffectError(`calling ${target}`);
+const getMutationError = (target: string) => getUnknownSideEffectError(`mutating ${target}`);
+
+const ERROR_ASSIGN_GLOBAL = getAssignmentError('global variable');
+const ERROR_CALL_DESTRUCTURED = getCallError('destructured variable');
+const ERROR_CALL_GLOBAL = getCallError('global function');
+const ERROR_CALL_IMPORT = getCallError('imported function');
+const ERROR_CALL_MEMBER = getCallError('member function');
+const ERROR_CALL_PARAMETER = getCallError('function parameter');
+const ERROR_CALL_RETURN_VALUE = getCallError('function return value');
+const ERROR_DEBUGGER = 'Debugger statements are side-effects';
+const ERROR_DELETE_OTHER = getUnknownSideEffectError('deleting anything but a MemberExpression');
+const ERROR_ITERATOR = getUnknownSideEffectError('iterating over an iterable');
+const ERROR_MUTATE_DESTRUCTURED = getMutationError('destructured variable');
+const ERROR_MUTATE_GLOBAL = getMutationError('global variable');
+const ERROR_MUTATE_IMPORT = getMutationError('imported variable');
+const ERROR_MUTATE_MEMBER = getMutationError('member');
+const ERROR_MUTATE_PARAMETER = getMutationError('function parameter');
+const ERROR_MUTATE_RETURN_VALUE = getMutationError('function return value');
+const ERROR_MUTATE_THIS = getMutationError('unknown this value');
+const ERROR_THROW = 'Throwing an error is a side-effect';
+
+const reportSideEffectsInProgram = (context: Rule.RuleContext, programNode: Program) => {
+ const checkedCalledNodes = new WeakSet();
+ const checkedNodesCalledWithNew = new WeakSet();
+ const checkedMutatedNodes = new WeakSet();
+
+ const DEFINITIONS: Partial> = {
+ ClassName: {
+ reportEffectsWhenCalled(definition, scope, options) {
+ reportSideEffectsWhenCalled(definition.node, scope, options);
+ },
+ },
+ FunctionName: {
+ reportEffectsWhenCalled(definition, scope, options) {
+ reportSideEffectsWhenCalled(definition.node, scope, options);
+ },
+ },
+ ImportBinding: {
+ reportEffectsWhenCalled(definition) {
+ checkedCalledNodes.add(definition);
+ if (checkedCalledNodes.has(definition.name)) {
+ return;
+ }
+ if (
+ !getTreeShakingComments(context.sourceCode.getCommentsBefore(definition.name)).has(
+ COMMENT_NO_SIDE_EFFECT_WHEN_CALLED,
+ ) &&
+ !isFunctionSideEffectFree(definition.name.name, definition.parent.source.value, context.options)
+ ) {
+ context.report({ node: definition.name, message: ERROR_CALL_IMPORT });
+ }
+ },
+ reportEffectsWhenMutated(definition) {
+ if (checkedMutatedNodes.has(definition.name)) {
+ return;
+ }
+ checkedMutatedNodes.add(definition.name);
+ context.report({ node: definition.name, message: ERROR_MUTATE_IMPORT });
+ },
+ },
+ Parameter: {
+ reportEffectsWhenCalled(definition) {
+ if (checkedCalledNodes.has(definition.name)) {
+ return;
+ }
+ checkedCalledNodes.add(definition.name);
+ context.report({ node: definition.name, message: ERROR_CALL_PARAMETER });
+ },
+ reportEffectsWhenMutated(definition) {
+ if (checkedMutatedNodes.has(definition.name)) {
+ return;
+ }
+ checkedMutatedNodes.add(definition.name);
+ context.report({ node: definition.name, message: ERROR_MUTATE_PARAMETER });
+ },
+ },
+ Variable: {
+ // side effects are already handled by checking write expressions in references
+ },
+ };
+
+ const BINARY_OPERATORS: Record any> = {
+ // eslint-disable-next-line eqeqeq
+ '==': (left, right) => left == right,
+ // eslint-disable-next-line eqeqeq
+ '!=': (left, right) => left != right,
+ '===': (left, right) => left === right,
+ '!==': (left, right) => left !== right,
+ '<': (left, right) => left < right,
+ '<=': (left, right) => left <= right,
+ '>': (left, right) => left > right,
+ '>=': (left, right) => left >= right,
+ '<<': (left, right) => left << right,
+ '>>': (left, right) => left >> right,
+ '>>>': (left, right) => left >>> right,
+ '+': (left, right) => left + right,
+ '-': (left, right) => left - right,
+ '*': (left, right) => left * right,
+ '/': (left, right) => left / right,
+ '%': (left, right) => left % right,
+ '|': (left, right) => left | right,
+ '^': (left, right) => left ^ right,
+ '&': (left, right) => left & right,
+ '**': (left, right) => Math.pow(left, right),
+ in: (left, right) => left in right,
+ instanceof: (left, right) => left instanceof right,
+ };
+
+ const LOGICAL_OPERATORS: Record any> = {
+ '&&': (getAndReportLeft, getAndReportRight) => {
+ const leftValue = getAndReportLeft();
+ if (!leftValue.hasValue) {
+ getAndReportRight();
+ return leftValue;
+ }
+ if (!leftValue.value) {
+ return leftValue;
+ }
+ return getAndReportRight();
+ },
+ '||': (getAndReportLeft, getAndReportRight) => {
+ const leftValue = getAndReportLeft();
+ if (!leftValue.hasValue) {
+ getAndReportRight();
+ return leftValue;
+ }
+ if (leftValue.value) {
+ return leftValue;
+ }
+ return getAndReportRight();
+ },
+ '??': (getAndReportLeft, getAndReportRight) => {
+ const leftValue = getAndReportLeft();
+ if (!leftValue.hasValue) {
+ getAndReportRight();
+ return leftValue;
+ }
+ if (leftValue.value) {
+ return leftValue;
+ }
+ return getAndReportRight();
+ },
+ };
+
+ const UNARY_OPERATORS: Record Value> = {
+ '-': value => Value.of(-value),
+ '+': value => Value.of(+value),
+ '!': value => Value.of(!value),
+ '~': value => Value.of(~value),
+ typeof: value => Value.of(typeof value),
+ void: () => Value.of(undefined),
+ delete: () => Value.unknown(),
+ };
+
+ // @ts-ignore TODO:
+ const NODES: ListenerMap = {
+ ArrayExpression: {
+ reportEffects(node, scope, options) {
+ node.elements.forEach(subNode => {
+ if (subNode) reportSideEffects(subNode, scope, options);
+ });
+ },
+ },
+
+ ArrayPattern: {
+ reportEffects(node, scope, options) {
+ node.elements.forEach(subNode => {
+ if (subNode) reportSideEffects(subNode, scope, options);
+ });
+ },
+ },
+
+ ArrowFunctionExpression: {
+ reportEffects: noEffects,
+ reportEffectsWhenCalled(node, scope, options) {
+ node.params.forEach(subNode => reportSideEffects(subNode, scope, options));
+ const functionScope = getChildScopeForNodeIfExists(node, scope);
+ if (!functionScope) {
+ reportFatalError(node, 'Could not find child scope for ArrowFunctionExpression.');
+ } else {
+ reportSideEffects(node.body, functionScope, options);
+ }
+ },
+ reportEffectsWhenMutated: noEffects,
+ },
+
+ AssignmentExpression: {
+ reportEffects(node, scope, options) {
+ reportSideEffectsWhenAssigned(node.left, scope, options);
+ reportSideEffects(node.right, scope, options);
+ },
+ },
+
+ AssignmentPattern: {
+ reportEffects(node, scope, options) {
+ reportSideEffects(node.left, scope, options);
+ reportSideEffects(node.right, scope, options);
+ },
+ },
+
+ AwaitExpression: {
+ reportEffects(node, scope, options) {
+ reportSideEffects(node.argument, scope, options);
+ },
+ },
+
+ BinaryExpression: {
+ getValueAndReportEffects(node, scope, options) {
+ const left = getValueAndReportSideEffects(node.left, scope, options);
+ const right = getValueAndReportSideEffects(node.right, scope, options);
+
+ if (left.hasValue && right.hasValue) {
+ return Value.of(BINARY_OPERATORS[node.operator](left.value, right.value));
+ }
+ return Value.unknown();
+ },
+ },
+
+ BlockStatement: {
+ reportEffects(node, scope, options) {
+ const blockScope = getChildScopeForNodeIfExists(node, scope) || scope;
+ node.body.forEach(subNode => reportSideEffects(subNode, blockScope, options));
+ },
+ },
+
+ BreakStatement: {
+ reportEffects: noEffects,
+ },
+
+ CallExpression: {
+ reportEffects(node, scope, options) {
+ node.arguments.forEach(subNode => reportSideEffects(subNode, scope, options));
+ reportSideEffectsWhenCalled(node.callee, scope, Object.assign({}, options, { calledWithNew: false }));
+ },
+ reportEffectsWhenCalled(node, scope) {
+ if (node.callee.type === 'Identifier') {
+ const localVariable = getLocalVariable(node.callee.name, scope);
+ if (localVariable && isLocalVariableAWhitelistedModule(localVariable, undefined, context.options)) {
+ return;
+ }
+ context.report({ node, message: ERROR_CALL_RETURN_VALUE });
+ }
+ },
+ reportEffectsWhenMutated(node) {
+ context.report({ node, message: ERROR_MUTATE_RETURN_VALUE });
+ },
+ },
+
+ CatchClause: {
+ reportEffects(node, scope, options) {
+ const catchScope = getChildScopeForNodeIfExists(node, scope);
+ if (!catchScope) {
+ reportFatalError(node, 'Could not find child scope for CatchClause.');
+ } else {
+ reportSideEffects(node.body, catchScope, options);
+ }
+ },
+ },
+
+ ChainExpression: {
+ reportEffects(node, scope, options) {
+ reportSideEffects(node.expression, scope, options);
+ },
+ },
+
+ ClassBody: {
+ reportEffects(node, scope, options) {
+ node.body.forEach(subNode => reportSideEffects(subNode, scope, options));
+ },
+ reportEffectsWhenCalled(node, scope, options) {
+ const classConstructor = node.body.find(
+ subNode => subNode.type === 'MethodDefinition' && subNode.kind === 'constructor',
+ );
+ if (classConstructor) {
+ reportSideEffectsWhenCalled(classConstructor, scope, options);
+ } else if ((options as any).superClass) {
+ reportSideEffectsWhenCalled((options as any).superClass, scope, options);
+ }
+
+ node.body
+ .filter(subNode => subNode.type === 'PropertyDefinition')
+ .forEach(subNode => reportSideEffectsWhenCalled(subNode, scope, options));
+ },
+ },
+
+ ClassDeclaration: {
+ reportEffects(node, scope, options) {
+ if (node.superClass) reportSideEffects(node.superClass, scope, options);
+ reportSideEffects(node.body, scope, options);
+ },
+ reportEffectsWhenCalled(node, scope, options) {
+ const classScope = getChildScopeForNodeIfExists(node, scope);
+ if (!classScope) {
+ reportFatalError(node, 'Could not find child scope for ClassDeclaration.');
+ } else {
+ reportSideEffectsWhenCalled(
+ node.body,
+ classScope,
+ Object.assign({}, options, { superClass: node.superClass }),
+ );
+ }
+ },
+ },
+
+ ClassExpression: {
+ reportEffects(node, scope, options) {
+ if (node.superClass) reportSideEffects(node.superClass, scope, options);
+ reportSideEffects(node.body, scope, options);
+ },
+ reportEffectsWhenCalled(node, scope, options) {
+ const classScope = getChildScopeForNodeIfExists(node, scope);
+ if (!classScope) {
+ reportFatalError(node, 'Could not find child scope for ClassExpression.');
+ } else {
+ reportSideEffectsWhenCalled(
+ node.body,
+ classScope,
+ Object.assign({}, options, { superClass: node.superClass }),
+ );
+ }
+ },
+ },
+
+ ConditionalExpression: {
+ getValueAndReportEffects(node, scope, options) {
+ const testResult = getValueAndReportSideEffects(node.test, scope, options);
+ if (testResult.hasValue) {
+ return testResult.value
+ ? getValueAndReportSideEffects(node.consequent, scope, options)
+ : getValueAndReportSideEffects(node.alternate, scope, options);
+ } else {
+ reportSideEffects(node.consequent, scope, options);
+ reportSideEffects(node.alternate, scope, options);
+ return testResult;
+ }
+ },
+ reportEffectsWhenCalled(node, scope, options) {
+ const testResult = getValueAndReportSideEffects(node.test, scope, options);
+ if (testResult.hasValue) {
+ return testResult.value
+ ? reportSideEffectsWhenCalled(node.consequent, scope, options)
+ : reportSideEffectsWhenCalled(node.alternate, scope, options);
+ } else {
+ reportSideEffectsWhenCalled(node.consequent, scope, options);
+ reportSideEffectsWhenCalled(node.alternate, scope, options);
+ }
+ },
+ },
+
+ ContinueStatement: {
+ reportEffects: noEffects,
+ },
+
+ DebuggerStatement: {
+ reportEffects(node) {
+ context.report({ node, message: ERROR_DEBUGGER });
+ },
+ },
+
+ DoWhileStatement: {
+ reportEffects(node, scope, options) {
+ reportSideEffects(node.test, scope, options);
+ reportSideEffects(node.body, scope, options);
+ },
+ },
+
+ EmptyStatement: {
+ reportEffects: noEffects,
+ },
+
+ ExportAllDeclaration: {
+ reportEffects: noEffects,
+ },
+
+ ExportDefaultDeclaration: {
+ reportEffects(node, scope, options) {
+ if (node.declaration.type !== 'FunctionDeclaration' && node.declaration.type !== 'ClassDeclaration') {
+ if (
+ getTreeShakingComments(context.sourceCode.getCommentsBefore(node.declaration)).has(
+ COMMENT_NO_SIDE_EFFECT_WHEN_CALLED,
+ )
+ ) {
+ reportSideEffectsWhenCalled(node.declaration, scope, options);
+ }
+ reportSideEffects(node.declaration, scope, options);
+ }
+ },
+ },
+
+ ExportNamedDeclaration: {
+ reportEffects(node, scope, options) {
+ node.specifiers.forEach(subNode => reportSideEffects(subNode, scope, options));
+ if (node.declaration) reportSideEffects(node.declaration, scope, options);
+ },
+ },
+
+ ExportSpecifier: {
+ reportEffects(node, scope, options) {
+ if (
+ getTreeShakingComments(context.sourceCode.getCommentsBefore(node.exported)).has(
+ COMMENT_NO_SIDE_EFFECT_WHEN_CALLED,
+ )
+ ) {
+ reportSideEffectsWhenCalled(node.exported, scope, options);
+ }
+ },
+ },
+
+ ExpressionStatement: {
+ reportEffects(node, scope, options) {
+ reportSideEffects(node.expression, scope, options);
+ },
+ },
+
+ ForInStatement: {
+ reportEffects(node, scope, options) {
+ const forScope = getChildScopeForNodeIfExists(node, scope) || scope;
+ if (node.left.type !== 'VariableDeclaration') {
+ reportSideEffectsWhenAssigned(node.left, forScope, options);
+ }
+ reportSideEffects(node.right, forScope, options);
+ reportSideEffects(node.body, forScope, options);
+ },
+ },
+
+ ForOfStatement: {
+ reportEffects(node, scope, options) {
+ const forScope = getChildScopeForNodeIfExists(node, scope) || scope;
+ if (node.left.type !== 'VariableDeclaration') {
+ reportSideEffectsWhenAssigned(node.left, forScope, options);
+ }
+ reportSideEffects(node.right, forScope, options);
+ reportSideEffects(node.body, forScope, options);
+ context.report({ node: node.right, message: ERROR_ITERATOR });
+ },
+ },
+
+ ForStatement: {
+ reportEffects(node, scope, options) {
+ const forScope = getChildScopeForNodeIfExists(node, scope) || scope;
+ if (node.init) reportSideEffects(node.init, forScope, options);
+ if (node.test) reportSideEffects(node.test, forScope, options);
+ if (node.update) reportSideEffects(node.update, forScope, options);
+ reportSideEffects(node.body, forScope, options);
+ },
+ },
+
+ FunctionDeclaration: {
+ reportEffects(node, scope, options) {
+ if (
+ node.id &&
+ getTreeShakingComments(context.sourceCode.getCommentsBefore(node.id)).has(COMMENT_NO_SIDE_EFFECT_WHEN_CALLED)
+ ) {
+ reportSideEffectsWhenCalled(node.id, scope, options);
+ }
+ },
+ reportEffectsWhenCalled(node, scope, options) {
+ node.params.forEach(subNode => reportSideEffects(subNode, scope, options));
+ const functionScope = getChildScopeForNodeIfExists(node, scope);
+ if (!functionScope) {
+ reportFatalError(node, 'Could not find child scope for FunctionDeclaration.');
+ } else {
+ reportSideEffects(
+ node.body,
+ functionScope,
+ Object.assign({}, options, { hasValidThis: (options as any).calledWithNew }),
+ );
+ }
+ },
+ },
+
+ FunctionExpression: {
+ reportEffects: noEffects,
+ reportEffectsWhenCalled(node, scope, options) {
+ node.params.forEach(subNode => reportSideEffects(subNode, scope, options));
+ const functionScope = getChildScopeForNodeIfExists(node, scope);
+ if (!functionScope) {
+ reportFatalError(node, 'Could not find child scope for FunctionExpression.');
+ } else {
+ reportSideEffects(
+ node.body,
+ functionScope,
+ Object.assign({}, options, { hasValidThis: (options as any).calledWithNew }),
+ );
+ }
+ },
+ },
+
+ Identifier: {
+ reportEffects: noEffects,
+ reportEffectsWhenAssigned(node, scope) {
+ if (!getLocalVariable(node.name, scope)) {
+ context.report({ node, message: ERROR_ASSIGN_GLOBAL });
+ }
+ },
+ reportEffectsWhenCalled(node, scope, options) {
+ if (isPureFunction(node, context)) {
+ return;
+ }
+
+ const variableInScope = getLocalVariable(node.name, scope);
+ if (variableInScope) {
+ // @ts-ignore TODO:
+ variableInScope.references.forEach(({ from, identifier, partial, writeExpr }) => {
+ if (partial) {
+ context.report({ node: identifier, message: ERROR_CALL_DESTRUCTURED });
+ } else {
+ writeExpr && reportSideEffectsWhenCalled(writeExpr, from, options);
+ }
+ });
+ variableInScope.defs.forEach(reportSideEffectsInDefinitionWhenCalled(variableInScope.scope, options));
+ } else {
+ context.report({ node, message: ERROR_CALL_GLOBAL });
+ }
+ },
+ reportEffectsWhenMutated(node, scope, options) {
+ const localVariable = getLocalVariable(node.name, scope);
+ if (localVariable) {
+ // @ts-ignore TODO:
+ localVariable.references.forEach(({ from, identifier, partial, writeExpr }) => {
+ if (partial) {
+ context.report({ node: identifier, message: ERROR_MUTATE_DESTRUCTURED });
+ } else {
+ writeExpr && reportSideEffectsWhenMutated(writeExpr, from, options);
+ }
+ });
+ localVariable.defs.forEach(reportSideEffectsInDefinitionWhenMutated(localVariable.scope, options));
+ } else {
+ context.report({ node, message: ERROR_MUTATE_GLOBAL });
+ }
+ },
+ },
+
+ IfStatement: {
+ reportEffects(node, scope, options) {
+ const testResult = getValueAndReportSideEffects(node.test, scope, options);
+ if (testResult.hasValue) {
+ if (testResult.value) {
+ reportSideEffects(node.consequent, scope, options);
+ } else if (node.alternate) {
+ reportSideEffects(node.alternate, scope, options);
+ }
+ } else {
+ reportSideEffects(node.consequent, scope, options);
+ if (node.alternate) reportSideEffects(node.alternate, scope, options);
+ }
+ },
+ },
+
+ ImportDeclaration: {
+ reportEffects: noEffects,
+ },
+
+ JSXAttribute: {
+ reportEffects(node, scope, options) {
+ if (node.value) reportSideEffects(node.value, scope, options);
+ },
+ },
+
+ JSXElement: {
+ reportEffects(node, scope, options) {
+ reportSideEffects(node.openingElement, scope, options);
+ // @ts-ignore TODO:
+ node.children.forEach(subNode => reportSideEffects(subNode, scope, options));
+ },
+ },
+
+ JSXEmptyExpression: {
+ reportEffects: noEffects,
+ },
+
+ JSXExpressionContainer: {
+ reportEffects(node, scope, options) {
+ reportSideEffects(node.expression, scope, options);
+ },
+ },
+
+ JSXIdentifier: {
+ reportEffectsWhenCalled(node, scope, options) {
+ if (isFirstLetterUpperCase(node.name)) {
+ const variableInScope = getLocalVariable(node.name, scope);
+ if (variableInScope) {
+ // @ts-ignore TODO:
+ variableInScope.references.forEach(({ from, identifier, partial, writeExpr }) => {
+ if (partial) {
+ context.report({ node: identifier, message: ERROR_CALL_DESTRUCTURED });
+ } else {
+ if (writeExpr)
+ reportSideEffectsWhenCalled(writeExpr, from, Object.assign({}, options, { calledWithNew: true }));
+ }
+ });
+ variableInScope.defs.forEach(
+ reportSideEffectsInDefinitionWhenCalled(
+ variableInScope.scope,
+ Object.assign({}, options, { calledWithNew: true }),
+ ),
+ );
+ } else {
+ context.report({ node, message: ERROR_CALL_GLOBAL });
+ }
+ }
+ },
+ },
+
+ JSXMemberExpression: {
+ reportEffectsWhenCalled(node) {
+ context.report({ node: node.property, message: ERROR_CALL_MEMBER });
+ },
+ },
+
+ JSXOpeningElement: {
+ reportEffects(node, scope, options) {
+ reportSideEffectsWhenCalled(node.name, scope, options);
+ node.attributes.forEach(subNode => reportSideEffects(subNode, scope, options));
+ },
+ },
+
+ JSXSpreadAttribute: {
+ reportEffects(node, scope, options) {
+ reportSideEffects(node.argument, scope, options);
+ },
+ },
+
+ JSXText: {
+ reportEffects: noEffects,
+ },
+
+ LabeledStatement: {
+ reportEffects(node, scope, options) {
+ reportSideEffects(node.body, scope, options);
+ },
+ },
+
+ Literal: {
+ getValueAndReportEffects(node) {
+ return Value.of(node.value);
+ },
+ },
+
+ LogicalExpression: {
+ getValueAndReportEffects(node, scope, options) {
+ return LOGICAL_OPERATORS[node.operator](
+ () => getValueAndReportSideEffects(node.left, scope, options),
+ () => getValueAndReportSideEffects(node.right, scope, options),
+ );
+ },
+ },
+
+ MemberExpression: {
+ reportEffects(node, scope, options) {
+ reportSideEffects(node.property, scope, options);
+ reportSideEffects(node.object, scope, options);
+ },
+ reportEffectsWhenAssigned(node, scope, options) {
+ reportSideEffects(node, scope, options);
+ reportSideEffectsWhenMutated(node.object, scope, options);
+ },
+ reportEffectsWhenMutated(node) {
+ context.report({ node: node.property, message: ERROR_MUTATE_MEMBER });
+ },
+ reportEffectsWhenCalled(node, scope, options) {
+ reportSideEffects(node, scope, options);
+ const rootNode = getRootNode(node);
+ if (rootNode.type !== 'Identifier') {
+ context.report({ node: node.property, message: ERROR_CALL_MEMBER });
+ return;
+ }
+ const localVariable = getLocalVariable(rootNode.name, scope);
+ if (localVariable) {
+ if (
+ (node.property.type === 'Identifier' &&
+ isLocalVariableAWhitelistedModule(localVariable, node.property.name, context.options)) ||
+ hasPureNotation(node, context)
+ ) {
+ return;
+ } else {
+ context.report({ node: node.property, message: ERROR_CALL_MEMBER });
+ return;
+ }
+ }
+ if (!isPureFunction(node, context)) {
+ context.report({ node: node.property, message: ERROR_CALL_MEMBER });
+ }
+ },
+ },
+
+ MetaProperty: {
+ reportEffects: noEffects,
+ },
+
+ MethodDefinition: {
+ reportEffects(node, scope, options) {
+ reportSideEffects(node.key, scope, options);
+ },
+ reportEffectsWhenCalled(node, scope, options) {
+ reportSideEffectsWhenCalled(node.value, scope, options);
+ },
+ },
+
+ NewExpression: {
+ reportEffects(node, scope, options) {
+ if (hasPureNotation(node, context)) {
+ return false;
+ }
+
+ node.arguments.forEach(subNode => reportSideEffects(subNode, scope, options));
+ reportSideEffectsWhenCalled(node.callee, scope, Object.assign({}, options, { calledWithNew: true }));
+ },
+ },
+
+ ObjectExpression: {
+ reportEffects(node, scope, options) {
+ node.properties.forEach(subNode => {
+ if (subNode.type === 'Property') {
+ reportSideEffects(subNode.key, scope, options);
+ reportSideEffects(subNode.value, scope, options);
+ }
+ });
+ },
+ reportEffectsWhenMutated: noEffects,
+ },
+
+ ObjectPattern: {
+ reportEffects(node, scope, options) {
+ node.properties.forEach(subNode => {
+ if (subNode.type === 'Property') {
+ reportSideEffects(subNode.key, scope, options);
+ reportSideEffects(subNode.value, scope, options);
+ }
+ });
+ },
+ },
+
+ PrivateIdentifier: {
+ reportEffects: noEffects,
+ },
+
+ PropertyDefinition: {
+ reportEffects(node, scope, options) {
+ reportSideEffects(node.key, scope, options);
+ },
+ reportEffectsWhenCalled(node, scope, options) {
+ if (node.value) reportSideEffects(node.value, scope, options);
+ },
+ },
+
+ RestElement: {
+ reportEffects: noEffects,
+ },
+
+ ReturnStatement: {
+ reportEffects(node, scope, options) {
+ if (node.argument) reportSideEffects(node.argument, scope, options);
+ },
+ },
+
+ SequenceExpression: {
+ getValueAndReportEffects(node, scope, options) {
+ return node.expressions.reduce(
+ (result, expression) => getValueAndReportSideEffects(expression, scope, options),
+ Value.unknown(),
+ );
+ },
+ },
+
+ Super: {
+ reportEffects: noEffects,
+ reportEffectsWhenCalled(node, scope, options) {
+ context.report({ node, message: getCallError('super') });
+ },
+ },
+
+ SwitchCase: {
+ reportEffects(node, scope, options) {
+ if (node.test) reportSideEffects(node.test, scope, options);
+ node.consequent.forEach(subNode => reportSideEffects(subNode, scope, options));
+ },
+ },
+
+ SwitchStatement: {
+ reportEffects(node, scope, options) {
+ reportSideEffects(node.discriminant, scope, options);
+ const switchScope = getChildScopeForNodeIfExists(node, scope);
+ if (!switchScope) {
+ reportFatalError(node, 'Could not find child scope for SwitchStatement.');
+ } else {
+ node.cases.forEach(subNode => reportSideEffects(subNode, switchScope, options));
+ }
+ },
+ },
+
+ TaggedTemplateExpression: {
+ reportEffects(node, scope, options) {
+ reportSideEffectsWhenCalled(node.tag, scope, options);
+ reportSideEffects(node.quasi, scope, options);
+ },
+ },
+
+ TemplateLiteral: {
+ reportEffects(node, scope, options) {
+ node.expressions.forEach(subNode => reportSideEffects(subNode, scope, options));
+ },
+ },
+
+ ThisExpression: {
+ reportEffects: noEffects,
+ reportEffectsWhenMutated(node, scope, options) {
+ // @ts-ignore TODO:
+ if (!options.hasValidThis) {
+ context.report({ node, message: ERROR_MUTATE_THIS });
+ }
+ },
+ },
+
+ ThrowStatement: {
+ reportEffects(node) {
+ context.report({ node, message: ERROR_THROW });
+ },
+ },
+
+ TryStatement: {
+ reportEffects(node, scope, options) {
+ reportSideEffects(node.block, scope, options);
+ if (node.handler) reportSideEffects(node.handler, scope, options);
+ if (node.finalizer) reportSideEffects(node.finalizer, scope, options);
+ },
+ },
+
+ UnaryExpression: {
+ getValueAndReportEffects(node, scope, options) {
+ if (node.operator === 'delete') {
+ if (node.argument.type !== 'MemberExpression') {
+ context.report({ node: node.argument, message: ERROR_DELETE_OTHER });
+ } else {
+ reportSideEffectsWhenMutated(node.argument.object, scope, options);
+ }
+ }
+ return getValueAndReportSideEffects(node.argument, scope, options).chain(UNARY_OPERATORS[node.operator]);
+ },
+ },
+
+ UpdateExpression: {
+ reportEffects(node, scope, options) {
+ // Increment/decrement work like "assign updated value", not like a mutation
+ // cf. y={};x={y};x.y++ => x.y={y:NaN}, y={}
+ reportSideEffectsWhenAssigned(node.argument, scope, options);
+ },
+ },
+
+ VariableDeclaration: {
+ reportEffects(node, scope, options) {
+ node.declarations.forEach(declarator => reportSideEffects(declarator, scope, options));
+ },
+ },
+
+ VariableDeclarator: {
+ reportEffects(node, scope, options) {
+ reportSideEffects(node.id, scope, options);
+ if (
+ getTreeShakingComments(context.sourceCode.getCommentsBefore(node.id)).has(COMMENT_NO_SIDE_EFFECT_WHEN_CALLED)
+ ) {
+ reportSideEffectsWhenCalled(node.id, scope, options);
+ }
+ if (node.init) reportSideEffects(node.init, scope, options);
+ },
+ },
+
+ WhileStatement: {
+ reportEffects(node, scope, options) {
+ reportSideEffects(node.test, scope, options);
+ reportSideEffects(node.body, scope, options);
+ },
+ },
+
+ YieldExpression: {
+ reportEffects(node, scope, options) {
+ if (node.argument) reportSideEffects(node.argument, scope, options);
+ },
+ },
+ };
+
+ const verifyNodeTypeIsKnown = (node: Node) => {
+ if (!node) {
+ return false;
+ }
+ if (!NODES[node.type]) {
+ if (!node.type.startsWith('TS')) {
+ reportFatalError(node, `Unknown node type ${node.type}.`);
+ }
+ return false;
+ }
+ return true;
+ };
+
+ function reportSideEffects(node: Node, scope: Scope.Scope, options: Rule.RuleContext['options']) {
+ if (!verifyNodeTypeIsKnown(node)) {
+ return;
+ }
+ const { reportEffects, getValueAndReportEffects } = NODES[node.type];
+ if (reportEffects) {
+ reportEffects(node as any, scope, options);
+ } else if (getValueAndReportEffects) {
+ getValueAndReportEffects(node as any, scope, options);
+ } else {
+ context.report({ node, message: getUnknownSideEffectError(node.type) });
+ }
+ }
+
+ function reportSideEffectsWhenAssigned(node: Node, scope: Scope.Scope, options: Rule.RuleContext['options']) {
+ if (!verifyNodeTypeIsKnown(node)) {
+ return;
+ }
+ const { reportEffectsWhenAssigned } = NODES[node.type];
+ if (reportEffectsWhenAssigned) {
+ reportEffectsWhenAssigned(node as any, scope, options);
+ } else {
+ context.report({ node, message: getAssignmentError(node.type) });
+ }
+ }
+
+ function reportSideEffectsWhenMutated(node: Node, scope: Scope.Scope, options: Rule.RuleContext['options']) {
+ if (!verifyNodeTypeIsKnown(node) || checkedMutatedNodes.has(node)) {
+ return;
+ }
+ checkedMutatedNodes.add(node);
+ const { reportEffectsWhenMutated } = NODES[node.type];
+ if (reportEffectsWhenMutated) {
+ reportEffectsWhenMutated(node as any, scope, options);
+ } else {
+ context.report({ node, message: getMutationError(node.type) });
+ }
+ }
+
+ function reportSideEffectsWhenCalled(node: Node, scope: Scope.Scope, options: Rule.RuleContext['options']) {
+ if (
+ !verifyNodeTypeIsKnown(node) ||
+ checkedCalledNodes.has(node) ||
+ ((options as any).calledWithNew && checkedNodesCalledWithNew.has(node))
+ ) {
+ return;
+ }
+ if ((options as any).calledWithNew) {
+ checkedNodesCalledWithNew.add(node);
+ } else {
+ checkedCalledNodes.add(node);
+ }
+ const { reportEffectsWhenCalled } = NODES[node.type];
+ if (reportEffectsWhenCalled) {
+ reportEffectsWhenCalled(node as any, scope, options);
+ } else {
+ context.report({ node, message: getCallError(node.type) });
+ }
+ }
+
+ function getValueAndReportSideEffects(node: Node, scope: Scope.Scope, options: Rule.RuleContext['options']): Value {
+ if (!verifyNodeTypeIsKnown(node)) {
+ return Value.unknown();
+ }
+ const { getValueAndReportEffects } = NODES[node.type];
+ if (getValueAndReportEffects) {
+ return getValueAndReportEffects(node as any, scope, options);
+ }
+ reportSideEffects(node, scope, options);
+ return Value.unknown();
+ }
+
+ const verifyDefinitionTypeIsKnown = (definition: Scope.Definition) => {
+ if (!DEFINITIONS[definition.type]) {
+ reportFatalError(definition.name, `Unknown definition type ${definition.type}.`);
+ return false;
+ }
+ return true;
+ };
+
+ function reportSideEffectsInDefinitionWhenCalled(scope: Scope.Scope, options: Rule.RuleContext['options']) {
+ return (definition: Scope.Definition) => {
+ if (!verifyDefinitionTypeIsKnown(definition)) {
+ return;
+ }
+ DEFINITIONS[definition.type]?.reportEffectsWhenCalled?.(definition as any, scope, options);
+ };
+ }
+
+ function reportSideEffectsInDefinitionWhenMutated(scope: Scope.Scope, options: Rule.RuleContext['options']) {
+ return (definition: Scope.Definition) => {
+ if (!verifyDefinitionTypeIsKnown(definition)) {
+ return;
+ }
+ DEFINITIONS[definition.type]?.reportEffectsWhenMutated?.(definition as any, scope, options);
+ };
+ }
+
+ function reportFatalError(node: Node, message: string) {
+ context.report({
+ node,
+ message:
+ message +
+ '\nIf you are using the latest version of this plugin, please ' +
+ 'consider filing an issue noting this message, the offending statement, your ESLint ' +
+ 'version, and any active ESLint presets and plugins',
+ });
+ }
+
+ const sourceCode = context.sourceCode;
+ const moduleScope = getChildScopeForNodeIfExists(programNode, sourceCode.getScope(programNode));
+ if (!moduleScope) {
+ reportFatalError(programNode, 'Could not find module scope.');
+ } else {
+ programNode.body.forEach(subNode => reportSideEffects(subNode, moduleScope, {} as any));
+ }
+};
+
+export const noSideEffectsInInitialization: Rule.RuleModule = {
+ meta: {
+ docs: {
+ description: 'disallow side-effects in module initialization',
+ category: 'Best Practices',
+ recommended: false,
+ },
+ schema: [
+ {
+ type: 'object',
+ properties: {
+ noSideEffectsWhenCalled: {
+ type: 'array',
+ items: {
+ anyOf: [
+ {
+ type: 'object',
+ properties: {
+ module: { type: 'string' },
+ functions: {
+ anyOf: [
+ { type: 'string', pattern: '^\\*$' },
+ { type: 'array', items: { type: 'string' } },
+ ],
+ },
+ },
+ additionalProperties: false,
+ },
+ {
+ type: 'object',
+ properties: {
+ function: { type: 'string' },
+ },
+ additionalProperties: false,
+ },
+ ],
+ },
+ },
+ },
+ additionalProperties: false,
+ },
+ ],
+ },
+ create: context => ({
+ Program: node => reportSideEffectsInProgram(context, node),
+ }),
+};
diff --git a/packages/eslint-plugin-tree-shaking/src/utils/helpers.ts b/packages/eslint-plugin-tree-shaking/src/utils/helpers.ts
new file mode 100644
index 00000000000..e66be635ea5
--- /dev/null
+++ b/packages/eslint-plugin-tree-shaking/src/utils/helpers.ts
@@ -0,0 +1,134 @@
+const TREE_SHAKING_COMMENT_ID = 'tree-shaking';
+
+import type { Node, Comment } from 'estree';
+import { pureFunctions } from '../utils/pure-functions.js';
+import type { Scope, Rule } from 'eslint';
+
+const getRootNode = (node: Node): Node => {
+ if (node.type === 'MemberExpression') {
+ return getRootNode(node.object);
+ }
+ return node;
+};
+
+const getChildScopeForNodeIfExists = (node: Node, currentScope: Scope.Scope) =>
+ currentScope.childScopes.find(scope => scope.block === node);
+
+const getLocalVariable = (variableName: string, scope: Scope.Scope): Scope.Variable | undefined => {
+ const variableInCurrentScope = scope.variables.find(({ name }) => name === variableName);
+ if (variableInCurrentScope) return variableInCurrentScope;
+
+ if (scope.upper && scope.upper.type !== 'global') return getLocalVariable(variableName, scope.upper);
+};
+
+const flattenMemberExpressionIfPossible = (node: Node): string | null => {
+ switch (node.type) {
+ case 'MemberExpression':
+ if (node.computed || node.property.type !== 'Identifier') {
+ return null;
+ }
+ // eslint-disable-next-line no-case-declarations
+ const flattenedParent = flattenMemberExpressionIfPossible(node.object);
+ return flattenedParent && `${flattenedParent}.${node.property.name}`;
+ case 'Identifier':
+ return node.name;
+ default:
+ return null;
+ }
+};
+
+const hasPureNotation = (node: Node, context: Rule.RuleContext) => {
+ const leadingComments = context.getSourceCode().getCommentsBefore(node);
+ if (leadingComments.length) {
+ const lastComment = leadingComments[leadingComments.length - 1].value;
+
+ // https://rollupjs.org/configuration-options/#treeshake-annotations
+ if (['@__PURE__', '#__PURE__'].includes(lastComment.trim())) {
+ return true;
+ }
+ }
+};
+
+const isPureFunction = (node: Node, context: Rule.RuleContext) => {
+ if (hasPureNotation(node, context)) return true;
+
+ const flattenedExpression = flattenMemberExpressionIfPossible(node);
+ if (context.options.length > 0) {
+ if (
+ context.options[0].noSideEffectsWhenCalled.find(
+ (whiteListedFunction: any) => whiteListedFunction.function === flattenedExpression,
+ )
+ ) {
+ return true;
+ }
+ }
+ return flattenedExpression && pureFunctions[flattenedExpression];
+};
+
+const noEffects = () => {};
+
+const parseComment = (comment: Comment) =>
+ comment.value
+ .split(' ')
+ .map(token => token.trim())
+ .filter(Boolean);
+
+const getTreeShakingComments = (comments: Comment[]) => {
+ const treeShakingComments = comments
+ .map(parseComment)
+ .filter(([id]) => id === TREE_SHAKING_COMMENT_ID)
+ .map(tokens => tokens.slice(1))
+ .reduce((result, tokens) => result.concat(tokens), []);
+ return { has: (token: string) => treeShakingComments.indexOf(token) >= 0 };
+};
+
+const isFunctionSideEffectFree = (
+ functionName: string | undefined,
+ moduleName: any,
+ contextOptions: Rule.RuleContext['options'],
+) => {
+ if (contextOptions.length === 0) {
+ return false;
+ }
+
+ for (const whiteListedFunction of contextOptions[0].noSideEffectsWhenCalled) {
+ if (
+ (whiteListedFunction.module === moduleName ||
+ (whiteListedFunction.module === '#local' && moduleName[0] === '.')) &&
+ (whiteListedFunction.functions === '*' || whiteListedFunction.functions.includes(functionName))
+ ) {
+ return true;
+ }
+ }
+ return false;
+};
+
+const isLocalVariableAWhitelistedModule = (
+ variable: Scope.Variable,
+ property: string | undefined,
+ contextOptions: Rule.RuleContext['options'],
+) => {
+ if (
+ variable.scope.type === 'module' &&
+ variable.defs[0].parent &&
+ variable.defs[0].parent.type === 'ImportDeclaration'
+ ) {
+ return isFunctionSideEffectFree(property, variable.defs[0].parent.source.value, contextOptions);
+ }
+ return false;
+};
+
+const isFirstLetterUpperCase = (string: string) => string[0] >= 'A' && string[0] <= 'Z';
+
+export {
+ getChildScopeForNodeIfExists,
+ getLocalVariable,
+ isLocalVariableAWhitelistedModule,
+ getRootNode,
+ getTreeShakingComments,
+ isFunctionSideEffectFree,
+ isFirstLetterUpperCase,
+ isPureFunction,
+ noEffects,
+ hasPureNotation,
+};
diff --git a/packages/eslint-plugin-tree-shaking/src/utils/pure-functions.ts b/packages/eslint-plugin-tree-shaking/src/utils/pure-functions.ts
new file mode 100644
index 00000000000..89f1122fcc5
--- /dev/null
+++ b/packages/eslint-plugin-tree-shaking/src/utils/pure-functions.ts
@@ -0,0 +1,139 @@
+// copied from rollup.js
+
+const pureFunctions: Record = {};
+
+const arrayTypes =
+ 'Array Int8Array Uint8Array Uint8ClampedArray Int16Array Uint16Array Int32Array Uint32Array Float32Array Float64Array'.split(
+ ' ',
+ );
+const simdTypes = 'Int8x16 Int16x8 Int32x4 Float32x4 Float64x2'.split(' ');
+const simdMethods =
+ 'abs add and bool check div equal extractLane fromFloat32x4 fromFloat32x4Bits fromFloat64x2 fromFloat64x2Bits fromInt16x8Bits fromInt32x4 fromInt32x4Bits fromInt8x16Bits greaterThan greaterThanOrEqual lessThan lessThanOrEqual load max maxNum min minNum mul neg not notEqual or reciprocalApproximation reciprocalSqrtApproximation replaceLane select selectBits shiftLeftByScalar shiftRightArithmeticByScalar shiftRightLogicalByScalar shuffle splat sqrt store sub swizzle xor'.split(
+ ' ',
+ );
+const allSimdMethods: string[] = [];
+simdTypes.forEach(t => {
+ simdMethods.forEach(m => {
+ allSimdMethods.push(`SIMD.${t}.${m}`);
+ });
+});
+[
+ 'Array.isArray',
+ 'Error',
+ 'EvalError',
+ 'InternalError',
+ 'RangeError',
+ 'ReferenceError',
+ 'SyntaxError',
+ 'TypeError',
+ 'URIError',
+ 'isFinite',
+ 'isNaN',
+ 'parseFloat',
+ 'parseInt',
+ 'decodeURI',
+ 'decodeURIComponent',
+ 'encodeURI',
+ 'encodeURIComponent',
+ 'escape',
+ 'unescape',
+ 'Object',
+ 'Object.create',
+ 'Object.getNotifier',
+ 'Object.getOwn',
+ 'Object.getOwnPropertyDescriptor',
+ 'Object.getOwnPropertyNames',
+ 'Object.getOwnPropertySymbols',
+ 'Object.getPrototypeOf',
+ 'Object.is',
+ 'Object.isExtensible',
+ 'Object.isFrozen',
+ 'Object.isSealed',
+ 'Object.keys',
+ 'Function',
+ 'Boolean',
+ 'Number',
+ 'Number.isFinite',
+ 'Number.isInteger',
+ 'Number.isNaN',
+ 'Number.isSafeInteger',
+ 'Number.parseFloat',
+ 'Number.parseInt',
+ 'Symbol',
+ 'Symbol.for',
+ 'Symbol.keyFor',
+ 'Math.abs',
+ 'Math.acos',
+ 'Math.acosh',
+ 'Math.asin',
+ 'Math.asinh',
+ 'Math.atan',
+ 'Math.atan2',
+ 'Math.atanh',
+ 'Math.cbrt',
+ 'Math.ceil',
+ 'Math.clz32',
+ 'Math.cos',
+ 'Math.cosh',
+ 'Math.exp',
+ 'Math.expm1',
+ 'Math.floor',
+ 'Math.fround',
+ 'Math.hypot',
+ 'Math.imul',
+ 'Math.log',
+ 'Math.log10',
+ 'Math.log1p',
+ 'Math.log2',
+ 'Math.max',
+ 'Math.min',
+ 'Math.pow',
+ 'Math.random',
+ 'Math.round',
+ 'Math.sign',
+ 'Math.sin',
+ 'Math.sinh',
+ 'Math.sqrt',
+ 'Math.tan',
+ 'Math.tanh',
+ 'Math.trunc',
+ 'Date',
+ 'Date.UTC',
+ 'Date.now',
+ 'Date.parse',
+ 'String',
+ 'String.fromCharCode',
+ 'String.fromCodePoint',
+ 'String.raw',
+ 'RegExp',
+ 'Map',
+ 'Set',
+ 'WeakMap',
+ 'WeakSet',
+ 'ArrayBuffer',
+ 'ArrayBuffer.isView',
+ 'DataView',
+ 'JSON.parse',
+ 'JSON.stringify',
+ 'Promise',
+ 'Promise.all',
+ 'Promise.race',
+ 'Promise.reject',
+ 'Promise.resolve',
+ 'Intl.Collator',
+ 'Intl.Collator.supportedLocalesOf',
+ 'Intl.DateTimeFormat',
+ 'Intl.DateTimeFormat.supportedLocalesOf',
+ 'Intl.NumberFormat',
+ 'Intl.NumberFormat.supportedLocalesOf',
+]
+ .concat(
+ arrayTypes,
+ arrayTypes.map(t => `${t}.from`),
+ arrayTypes.map(t => `${t}.of`),
+ simdTypes.map(t => `SIMD.${t}`),
+ allSimdMethods,
+ )
+ .forEach(name => (pureFunctions[name] = true));
+
+export { pureFunctions };
diff --git a/packages/eslint-plugin-tree-shaking/src/utils/value.ts b/packages/eslint-plugin-tree-shaking/src/utils/value.ts
new file mode 100644
index 00000000000..99dc31a87db
--- /dev/null
+++ b/packages/eslint-plugin-tree-shaking/src/utils/value.ts
@@ -0,0 +1,31 @@
+class Value {
+ value!: T;
+ hasValue?: boolean;
+
+ static of(value: T) {
+ return new Known(value);
+ }
+
+ static unknown() {
+ return new Unknown();
+ }
+
+ chain(mappingFunction: (value: T) => Value) {
+ if (this.hasValue) {
+ return mappingFunction(this.value);
+ }
+ return this;
+ }
+}
+
+class Known extends Value {
+ constructor(value: T) {
+ super();
+ this.value = value;
+ this.hasValue = true;
+ }
+}
+
+class Unknown extends Value {}
+
+export { Value };
diff --git a/packages/eslint-plugin-tree-shaking/tsconfig.json b/packages/eslint-plugin-tree-shaking/tsconfig.json
new file mode 100644
index 00000000000..8dbe02c3665
--- /dev/null
+++ b/packages/eslint-plugin-tree-shaking/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "outDir": "dist",
+ "strict": true,
+ "target": "ES2019",
+ "moduleResolution": "NodeNext",
+ "module": "NodeNext"
+ },
+ "include": ["src"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cce2c25ef42..b09cc348740 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -67,6 +67,9 @@ importers:
'@clerk/backend':
specifier: workspace:*
version: link:packages/backend
+ '@clerk/eslint-plugin-tree-shaking':
+ specifier: workspace:*
+ version: link:packages/eslint-plugin-tree-shaking
'@clerk/shared':
specifier: workspace:*
version: link:packages/shared
@@ -623,6 +626,18 @@ importers:
specifier: 14.2.33
version: 14.2.33(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.54.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ packages/eslint-plugin-tree-shaking:
+ devDependencies:
+ '@types/estree':
+ specifier: ^1.0.8
+ version: 1.0.8
+ '@types/estree-jsx':
+ specifier: ^1.0.5
+ version: 1.0.5
+ eslint:
+ specifier: 9.31.0
+ version: 9.31.0(jiti@2.5.1)
+
packages/expo:
dependencies:
'@clerk/clerk-js':
@@ -2760,7 +2775,7 @@ packages:
'@expo/bunyan@4.0.1':
resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==}
- engines: {node: '>=0.10.0'}
+ engines: {'0': node >=0.10.0}
'@expo/cli@0.22.26':
resolution: {integrity: sha512-I689wc8Fn/AX7aUGiwrh3HnssiORMJtR2fpksX+JIe8Cj/EDleblYMSwRPd0025wrwOV9UN1KM/RuEt/QjCS3Q==}
@@ -5155,6 +5170,9 @@ packages:
'@types/diff-match-patch@1.0.36':
resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==}
+ '@types/estree-jsx@1.0.5':
+ resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
+
'@types/estree@1.0.5':
resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
@@ -20123,6 +20141,10 @@ snapshots:
'@types/diff-match-patch@1.0.36': {}
+ '@types/estree-jsx@1.0.5':
+ dependencies:
+ '@types/estree': 1.0.8
+
'@types/estree@1.0.5': {}
'@types/estree@1.0.8': {}
diff --git a/turbo.json b/turbo.json
index ea0fd5f3d4f..f32b5962487 100644
--- a/turbo.json
+++ b/turbo.json
@@ -102,7 +102,7 @@
"cache": false
},
"lint": {
- "dependsOn": ["^build"],
+ "dependsOn": ["@clerk/eslint-plugin-tree-shaking#build", "^build"],
"inputs": [
"**/*.js",
"**/*.jsx",
From 96a430f7af18eb55bfb732c993e3b62ebfed2281 Mon Sep 17 00:00:00 2001
From: Dylan Staley <88163+dstaley@users.noreply.github.com>
Date: Sat, 4 Oct 2025 14:49:35 -0500
Subject: [PATCH 2/3] chore(repo): Add empty changeset
---
.changeset/lemon-bugs-check.md | 2 ++
1 file changed, 2 insertions(+)
create mode 100644 .changeset/lemon-bugs-check.md
diff --git a/.changeset/lemon-bugs-check.md b/.changeset/lemon-bugs-check.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/lemon-bugs-check.md
@@ -0,0 +1,2 @@
+---
+---
From 23dc0f2c21dce616b5a37731663f43d89ba2fce7 Mon Sep 17 00:00:00 2001
From: Dylan Staley <88163+dstaley@users.noreply.github.com>
Date: Mon, 6 Oct 2025 10:32:27 -0500
Subject: [PATCH 3/3] feat(eslint-plugin-tree-shaking): Use native TypeScript
support
---
packages/eslint-plugin-tree-shaking/package.json | 8 +-------
packages/eslint-plugin-tree-shaking/src/index.ts | 2 +-
.../src/rules/no-side-effects-in-initialization.ts | 8 ++++----
packages/eslint-plugin-tree-shaking/src/utils/helpers.ts | 2 +-
packages/eslint-plugin-tree-shaking/tsconfig.json | 5 +++--
turbo.json | 2 +-
6 files changed, 11 insertions(+), 16 deletions(-)
diff --git a/packages/eslint-plugin-tree-shaking/package.json b/packages/eslint-plugin-tree-shaking/package.json
index 0b80dd2e3ce..6ba47cd81de 100644
--- a/packages/eslint-plugin-tree-shaking/package.json
+++ b/packages/eslint-plugin-tree-shaking/package.json
@@ -4,13 +4,7 @@
"private": true,
"type": "module",
"exports": {
- ".": {
- "types": "./dist/index.d.ts",
- "default": "./dist/index.js"
- }
- },
- "scripts": {
- "build": "tsc"
+ ".": "./src/index.ts"
},
"devDependencies": {
"@types/estree": "^1.0.8",
diff --git a/packages/eslint-plugin-tree-shaking/src/index.ts b/packages/eslint-plugin-tree-shaking/src/index.ts
index 1c0e64e02b5..2b9cc4eaca3 100644
--- a/packages/eslint-plugin-tree-shaking/src/index.ts
+++ b/packages/eslint-plugin-tree-shaking/src/index.ts
@@ -1,5 +1,5 @@
import type { ESLint } from 'eslint';
-import { noSideEffectsInInitialization } from './rules/no-side-effects-in-initialization.js';
+import { noSideEffectsInInitialization } from './rules/no-side-effects-in-initialization.ts';
const plugin: ESLint.Plugin = {
meta: {
diff --git a/packages/eslint-plugin-tree-shaking/src/rules/no-side-effects-in-initialization.ts b/packages/eslint-plugin-tree-shaking/src/rules/no-side-effects-in-initialization.ts
index 62ede7eca75..c95857f3f7b 100644
--- a/packages/eslint-plugin-tree-shaking/src/rules/no-side-effects-in-initialization.ts
+++ b/packages/eslint-plugin-tree-shaking/src/rules/no-side-effects-in-initialization.ts
@@ -5,8 +5,8 @@
// https://npmdoc.github.io/node-npmdoc-escope/build/apidoc.html
///
-import { Rule, Scope } from 'eslint';
-import { Program, Node, BinaryOperator, LogicalOperator, UnaryOperator } from 'estree';
+import type { Rule, Scope } from 'eslint';
+import type { Program, Node, BinaryOperator, LogicalOperator, UnaryOperator } from 'estree';
import {
getChildScopeForNodeIfExists,
isLocalVariableAWhitelistedModule,
@@ -18,8 +18,8 @@ import {
isFunctionSideEffectFree,
noEffects,
hasPureNotation,
-} from '../utils/helpers.js';
-import { Value } from '../utils/value.js';
+} from '../utils/helpers.ts';
+import { Value } from '../utils/value.ts';
type ListenerArgs = [
node: T extends { type: K } ? T : never,
diff --git a/packages/eslint-plugin-tree-shaking/src/utils/helpers.ts b/packages/eslint-plugin-tree-shaking/src/utils/helpers.ts
index e66be635ea5..951216ad1ca 100644
--- a/packages/eslint-plugin-tree-shaking/src/utils/helpers.ts
+++ b/packages/eslint-plugin-tree-shaking/src/utils/helpers.ts
@@ -1,7 +1,7 @@
const TREE_SHAKING_COMMENT_ID = 'tree-shaking';
import type { Node, Comment } from 'estree';
-import { pureFunctions } from '../utils/pure-functions.js';
+import { pureFunctions } from '../utils/pure-functions.ts';
import type { Scope, Rule } from 'eslint';
const getRootNode = (node: Node): Node => {
diff --git a/packages/eslint-plugin-tree-shaking/tsconfig.json b/packages/eslint-plugin-tree-shaking/tsconfig.json
index 8dbe02c3665..03acf33f4fc 100644
--- a/packages/eslint-plugin-tree-shaking/tsconfig.json
+++ b/packages/eslint-plugin-tree-shaking/tsconfig.json
@@ -1,10 +1,11 @@
{
"compilerOptions": {
- "outDir": "dist",
+ "noEmit": true,
"strict": true,
"target": "ES2019",
"moduleResolution": "NodeNext",
- "module": "NodeNext"
+ "module": "NodeNext",
+ "allowImportingTsExtensions": true
},
"include": ["src"]
}
diff --git a/turbo.json b/turbo.json
index f32b5962487..ea0fd5f3d4f 100644
--- a/turbo.json
+++ b/turbo.json
@@ -102,7 +102,7 @@
"cache": false
},
"lint": {
- "dependsOn": ["@clerk/eslint-plugin-tree-shaking#build", "^build"],
+ "dependsOn": ["^build"],
"inputs": [
"**/*.js",
"**/*.jsx",