Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/code-infra/src/eslint/material-ui/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import muiNameMatchesComponentName from './rules/mui-name-matches-component-name
import noEmptyBox from './rules/no-empty-box.mjs';
import noRestrictedResolvedImports from './rules/no-restricted-resolved-imports.mjs';
import noStyledBox from './rules/no-styled-box.mjs';
import requireDevWrapper from './rules/require-dev-wrapper.mjs';
import rulesOfUseThemeVariants from './rules/rules-of-use-theme-variants.mjs';
import straightQuotes from './rules/straight-quotes.mjs';

Expand All @@ -23,5 +24,6 @@ export default /** @type {import('eslint').ESLint.Plugin} */ ({
'straight-quotes': straightQuotes,
'disallow-react-api-in-server-components': disallowReactApiInServerComponents,
'no-restricted-resolved-imports': noRestrictedResolvedImports,
'require-dev-wrapper': requireDevWrapper,
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* ESLint rule that enforces certain function calls to be wrapped with
* a production check to prevent them from ending up in production bundles.
*
* @example
* // Valid - function wrapped with production check
* if (process.env.NODE_ENV !== 'production') {
* checkSlot(key, overrides[k]);
* }
*
* @example
* // Invalid - function not wrapped
* checkSlot(key, overrides[k]); // Will trigger error
*
* @example
* // Invalid - wrong condition (=== instead of !==)
Copy link
Member

@Janpot Janpot Oct 7, 2025

Choose a reason for hiding this comment

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

This feels wrong to me. I believe what we want is to enforce mutual exclusive checks.

// valid
if (process.env.NODE_ENV !== 'production') {}

// valid
if (process.env.NODE_ENV === 'production') {}

// invalid (only compare with production, write !== 'production' instead)
if (process.env.NODE_ENV === 'development') {}

// invalid (only consider production vs.test)
if (process.env.NODE_ENV !== 'test') {}

// invalid (not statically analyzable construct)
const env = 'production';
if (process.env.NODE_ENV !== env) {}

// invalid (not statically analyzable construct)
if (foo(process.env.NODE_ENV)) {}
// i.e. anything outside a comparison binary operation

Copy link
Contributor

Choose a reason for hiding this comment

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

@copilot Update the code as per this comment ^

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated the rule to accept both === and !== comparisons with 'production', while rejecting comparisons with other values like 'development' or 'test', and non-static constructs. Fixed in commit cd90e72.

Copy link
Member

Choose a reason for hiding this comment

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

@copilot Can we instead split this into two rules?

  • require-dev-wrapper: to detect functions that require a production guard
  • consistent-production-guard: which make sure these rules are followed:
    // valid
    if (process.env.NODE_ENV !== 'production') {}
    
    // valid
    if (process.env.NODE_ENV === 'production') {}
    
    // invalid (only compare with production, write !== 'production' instead)
    if (process.env.NODE_ENV === 'development') {}
    
    // invalid (only consider production vs.test)
    if (process.env.NODE_ENV !== 'test') {}
    
    // invalid (not statically analyzable construct)
    const env = 'production';
    if (process.env.NODE_ENV !== env) {}
    
    // invalid (not statically analyzable construct)
    if (foo(process.env.NODE_ENV)) {}
    // i.e. anything outside a comparison binary operation

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Split into two separate rules as requested:

  1. require-dev-wrapper - Detects functions that need production guards (simplified to only check if wrapped)
  2. consistent-production-guard - Enforces production guard patterns (only 'production' literal, static checks, etc.)

Both rules have comprehensive test coverage and can be enabled independently. Changes in commit 911cf1b.

* if (process.env.NODE_ENV === 'production') {
* checkSlot(key, overrides[k]); // Will trigger error
* }
*
* @example
* // Usage in ESLint config
* {
* rules: {
* 'material-ui/require-dev-wrapper': ['error', {
* functionNames: ['warnOnce', 'warn', 'checkSlot']
* }]
* }
* }
*
* @type {import('eslint').Rule.RuleModule}
*/
const rule = {
meta: {
type: 'problem',
docs: {
description:
'Enforce that certain function calls are wrapped with a production check to prevent them from ending up in production bundles',
},
messages: {
missingDevWrapper:
"Function `{{ functionName }}` must be wrapped with `if (process.env.NODE_ENV !== 'production')` to prevent it from ending up in production bundles.",
wrongCondition:
"Function `{{ functionName }}` must be wrapped with `if (process.env.NODE_ENV !== 'production')` (not `===`).",
},
schema: [
{
type: 'object',
properties: {
functionNames: {
type: 'array',
items: {
type: 'string',
},
default: ['warnOnce', 'warn', 'checkSlot'],
},
},
additionalProperties: false,
},
],
},
create(context) {
const options = context.options[0] || {};
const functionNames = options.functionNames || ['warnOnce', 'warn', 'checkSlot'];

/**
* Checks if a node is wrapped in a production check conditional
* @param {import('estree').Node & import('eslint').Rule.NodeParentExtension} node
* @returns {{ wrapped: boolean; wrongCondition: boolean }}
*/
function isWrappedInProductionCheck(node) {
let current = node.parent;

while (current) {
// Check if we're inside an if statement
if (current.type === 'IfStatement') {
const test = current.test;

// Make sure we're in the consequent (then) block, not the alternate (else) block
let isInConsequent = false;
let temp = node;
while (temp && temp !== current) {
if (temp === current.consequent) {
isInConsequent = true;
break;
}
temp = temp.parent;
}

if (!isInConsequent) {
// Continue looking up the tree if we're in the alternate branch
current = current.parent;
continue;
}

// Check for: process.env.NODE_ENV !== 'production'
if (
test.type === 'BinaryExpression' &&
test.operator === '!==' &&
isProcessEnvNodeEnv(test.left) &&
test.right.type === 'Literal' &&
test.right.value === 'production'
) {
return { wrapped: true, wrongCondition: false };
}

// Check for wrong condition: process.env.NODE_ENV === 'production'
if (
Copy link
Member

Choose a reason for hiding this comment

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

@copilot let's remove this we'll do it in isNodeEnvComparison

// check exact case here
// ...
if (operator === '!==') {
  // also check this case
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Moved the logic into isNodeEnvComparison. When operator is !==, it now checks both the exact case and any other NODE_ENV comparison. Removed the redundant second check.

Fixed in commit 651aa6a.

test.type === 'BinaryExpression' &&
test.operator === '===' &&
isProcessEnvNodeEnv(test.left) &&
test.right.type === 'Literal' &&
test.right.value === 'production'
) {
return { wrapped: true, wrongCondition: true };
}
}

current = current.parent;
}

return { wrapped: false, wrongCondition: false };
}

/**
* Checks if a node is process.env.NODE_ENV
* @param {import('estree').Node} node
* @returns {boolean}
*/
function isProcessEnvNodeEnv(node) {
return (
node.type === 'MemberExpression' &&
node.object.type === 'MemberExpression' &&
node.object.object.type === 'Identifier' &&
node.object.object.name === 'process' &&
node.object.property.type === 'Identifier' &&
node.object.property.name === 'env' &&
node.property.type === 'Identifier' &&
node.property.name === 'NODE_ENV'
);
}

return {
CallExpression(node) {
// Check if the callee is one of the restricted function names
if (node.callee.type === 'Identifier' && functionNames.includes(node.callee.name)) {
const { wrapped, wrongCondition } = isWrappedInProductionCheck(node);

if (!wrapped) {
context.report({
node,
messageId: 'missingDevWrapper',
data: {
functionName: node.callee.name,
},
});
} else if (wrongCondition) {
context.report({
node,
messageId: 'wrongCondition',
data: {
functionName: node.callee.name,
},
});
}
}
},
};
},

Check failure on line 169 in packages/code-infra/src/eslint/material-ui/rules/require-dev-wrapper.mjs

View workflow job for this annotation

GitHub Actions / copilot

src/eslint/material-ui/rules/require-dev-wrapper.test.mjs > require-dev-wrapper > invalid > if (someOtherCondition) { checkSlot(key, value); }

RangeError: Maximum call stack size exceeded Occurred while linting <input>:3 Rule: "rule-to-test/require-dev-wrapper" ❯ containsProcessEnvNodeEnv src/eslint/material-ui/rules/require-dev-wrapper.mjs:169:14 ❯ containsProcessEnvNodeEnv src/eslint/material-ui/rules/require-dev-wrapper.mjs:185:22 ❯ containsProcessEnvNodeEnv src/eslint/material-ui/rules/require-dev-wrapper.mjs:185:22 ❯ containsProcessEnvNodeEnv src/eslint/material-ui/rules/require-dev-wrapper.mjs:181:55 ❯ containsProcessEnvNodeEnv src/eslint/material-ui/rules/require-dev-wrapper.mjs:185:22 ❯ containsProcessEnvNodeEnv src/eslint/material-ui/rules/require-dev-wrapper.mjs:181:55 ❯ containsProcessEnvNodeEnv src/eslint/material-ui/rules/require-dev-wrapper.mjs:185:22 ❯ containsProcessEnvNodeEnv src/eslint/material-ui/rules/require-dev-wrapper.mjs:181:55 ❯ containsProcessEnvNodeEnv src/eslint/material-ui/rules/require-dev-wrapper.mjs:185:22 ❯ containsProcessEnvNodeEnv src/eslint/material-ui/rules/require-dev-wrapper.mjs:181:55 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { ruleId: 'rule-to-test/require-dev-wrapper', currentNode: { type: 'CallExpression', arguments: [ { type: 'Identifier', decorators: [], name: 'key', optional: false, typeAnnotation: undefined, range: [ 39, 42 ], loc: { end: { column: 15, line: 3 }, start: { column: 12, line: 3 } }, start: '<unserializable>: Use node.range[0] instead of node.start', end: '<unserializable>: Use node.range[1] instead of node.end', parent: [Circular] }, { type: 'Identifier', decorators: [], name: 'value', optional: false, typeAnnotation: undefined, range: [ 44, 49 ], loc: { end: { column: 22, line: 3 }, start: { column: 17, line: 3 } }, start: '<unserializable>: Use node.range[0] instead of node.start', end: '<unserializable>: Use node.range[1] instead of node.end', parent: [Circular] } ], callee: { type: 'Identifier', decorators: [], name: 'checkSlot', optional: false, typeAnnotation: undefined, range: [ 29, 38 ], loc: { end: { column: 11, line: 3 }, start: { column: 2, line: 3 } }, start: '<unserializable>: Use node.range[0] instead of node.start', end: '<unserializable>: Use node.range[1] instead of node.end', parent: [Circular] }, optional: false, typeArguments: undefined, range: [ 29, 50 ], loc: { end: { column: 23, line: 3 }, start: { column: 2, line: 3 } }, start: '<unserializable>: Use node.range[0] instead of node.start', end: '<unserializable>: Use node.range[1] instead of node.end', parent: { type: 'ExpressionStatement', directive: undefined, expression: [Circular], range: [ 29, 51 ], loc: { end: { column: 24, line: 3 }, start: { column: 2, line: 3 } }, start: '<unserializable>: Use node.range[0] instead of node.start', end: '<unserializable>: Use node.range[1] instead of node.end', parent: { type: 'BlockStatement', body: [ [Circular] ], range: [ 25, 53 ], loc: { end: { column: 1, line: 4 }, start: { column: 24, line: 2 } }, start: '<unserializable>: Use node.range[0] instead of node.start', end: '<unserializable>: Use node.range[1] instead of node.end', parent: { type: 'IfStatement', alternate: null, consequent: [Circular], test: { type: 'Identifier', decorators: [], name: 'someOtherCondition', optional: false, typeAnnotation: undefined, range: [ 5, 23 ], loc: { end: { column: 22, line: 2 }, start: { column: 4, line: 2 } }, start: '<unserializable>: Use node.range[0] instead of node.start', end: '<unserializable>: Use node.range[1] instead of node.end', parent: [Circular] }, range: [ 1, 53 ], loc: { end: { column: 1, line: 4 }, start: { column: +0, line: 2 } }, start: '<unserializable>: Use node.range[0] instead of node.start', end: '<unserializable>: Use node.range[1] instead of node.end', parent: { type: 'Program', range: [ 1, 60 ], body: [ [Circular] ], comments: [], sourceType: 'module', tokens: [ { type: 'Keyword', loc: { end: { column: 2, line: 2 }, start: { column: +0, line: 2 } }, range: [ 1, 3 ], value: 'if', start: '<unserializable>: Use token.range[0] instead of token.start', end: '<unseriali
};

export default rule;
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import eslint from 'eslint';
import parser from '@typescript-eslint/parser';
import rule from './require-dev-wrapper.mjs';

const ruleTester = new eslint.RuleTester({
languageOptions: {
parser,
},
});

ruleTester.run('require-dev-wrapper', rule, {
valid: [
// Should pass: Function wrapped with correct production check
{
code: `
if (process.env.NODE_ENV !== 'production') {
checkSlot(key, overrides[k]);
}
`,
},
// Should pass: Function wrapped in a for loop inside production check
{
code: `
if (process.env.NODE_ENV !== 'production') {
for (key in slots) {
checkSlot(key, overrides[k]);
}
}
`,
},
// Should pass: Other functions not in the list
{
code: `
otherFunction('hello');
`,
},
// Should pass: warnOnce wrapped correctly
{
code: `
if (process.env.NODE_ENV !== 'production') {
warnOnce('Some warning message');
}
`,
},
// Should pass: warn wrapped correctly
{
code: `
if (process.env.NODE_ENV !== 'production') {
warn('Some warning message');
}
`,
},
// Should pass: Multiple statements in production check
{
code: `
if (process.env.NODE_ENV !== 'production') {
const message = 'Warning';
warn(message);
checkSlot(key, value);
}
`,
},
// Should pass: Nested blocks
{
code: `
if (process.env.NODE_ENV !== 'production') {
if (someCondition) {
warnOnce('nested warning');
}
}
`,
},
],
invalid: [
// Should fail: checkSlot without production check
{
code: `
checkSlot(key, overrides[k]);
`,
errors: [
{
messageId: 'missingDevWrapper',
data: { functionName: 'checkSlot' },
},
],
},
// Should fail: Wrong condition (=== instead of !==)
{
code: `
if (process.env.NODE_ENV === 'production') {
checkSlot(key, overrides[k]);
}
`,
errors: [
{
messageId: 'wrongCondition',
data: { functionName: 'checkSlot' },
},
],
},
// Should fail: warnOnce without production check
{
code: `
warnOnce('Some warning');
`,
errors: [
{
messageId: 'missingDevWrapper',
data: { functionName: 'warnOnce' },
},
],
},
// Should fail: warn without production check
{
code: `
warn('Some warning');
`,
errors: [
{
messageId: 'missingDevWrapper',
data: { functionName: 'warn' },
},
],
},
// Should fail: Multiple unwrapped calls
{
code: `
checkSlot(key, value);
warn('Warning message');
`,
errors: [
{
messageId: 'missingDevWrapper',
data: { functionName: 'checkSlot' },
},
{
messageId: 'missingDevWrapper',
data: { functionName: 'warn' },
},
],
},
// Should fail: Inside wrong conditional
{
code: `
if (someOtherCondition) {
checkSlot(key, value);
}
`,
errors: [
{
messageId: 'missingDevWrapper',
data: { functionName: 'checkSlot' },
},
],
},
// Should fail: Inside else block of production check
{
code: `
if (process.env.NODE_ENV !== 'production') {
// ok
} else {
checkSlot(key, value);
}
`,
errors: [
{
messageId: 'missingDevWrapper',
data: { functionName: 'checkSlot' },
},
],
},
],
});
Loading