Skip to content
Draft
Show file tree
Hide file tree
Changes from 11 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
4 changes: 4 additions & 0 deletions packages/code-infra/src/eslint/material-ui/index.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import consistentProductionGuard from './rules/consistent-production-guard.mjs';
import disallowActiveElementAsKeyEventTarget from './rules/disallow-active-element-as-key-event-target.mjs';
import disallowReactApiInServerComponents from './rules/disallow-react-api-in-server-components.mjs';
import docgenIgnoreBeforeComment from './rules/docgen-ignore-before-comment.mjs';
import muiNameMatchesComponentName from './rules/mui-name-matches-component-name.mjs';
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 @@ -14,6 +16,7 @@ export default /** @type {import('eslint').ESLint.Plugin} */ ({
version: '0.1.0',
},
rules: {
'consistent-production-guard': consistentProductionGuard,
'disallow-active-element-as-key-event-target': disallowActiveElementAsKeyEventTarget,
'docgen-ignore-before-comment': docgenIgnoreBeforeComment,
'mui-name-matches-component-name': muiNameMatchesComponentName,
Expand All @@ -23,5 +26,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,107 @@
import { isProcessEnvNodeEnv, isLiteral } from './nodeEnvUtils.mjs';

/**
* ESLint rule that enforces consistent patterns for production guard checks.
*
* @example
* // Valid - comparing with 'production'
* if (process.env.NODE_ENV !== 'production') {}
*
* @example
* // Valid - comparing with 'production'
* if (process.env.NODE_ENV === 'production') {}
*
* @example
* // Invalid - comparing with 'development'
* if (process.env.NODE_ENV === 'development') {}
*
* @example
* // Invalid - comparing with 'test'
* if (process.env.NODE_ENV !== 'test') {}
*
* @example
* // Invalid - non-static construct
* const env = 'production';
* if (process.env.NODE_ENV !== env) {}
*
* @example
* // Usage in ESLint config
* {
* rules: {
* 'material-ui/consistent-production-guard': 'error'
* }
* }
*
* @type {import('eslint').Rule.RuleModule}
*/
const rule = {
meta: {
type: 'problem',
docs: {
description:
'Enforce consistent patterns for production guard checks using process.env.NODE_ENV',
},
messages: {
invalidComparison:
"Only compare process.env.NODE_ENV with 'production'. Use `process.env.NODE_ENV !== 'production'` or `process.env.NODE_ENV === 'production'` instead of comparing with '{{ comparedValue }}'.",
invalidUsage:
"process.env.NODE_ENV must be used in a binary comparison with === or !== and a literal 'production'. Use `process.env.NODE_ENV !== 'production'` or `process.env.NODE_ENV === 'production'`.",
},
schema: [],
},
create(context) {
/**
* Check if a guard is valid (process.env.NODE_ENV compared with literal 'production')
* @param {import('estree').Node} envNode - The node that might be process.env.NODE_ENV
* @param {import('estree').Node} valueNode - The node being compared with
* @param {import('estree').BinaryExpression} binaryNode - The binary expression node
*/
function checkGuard(envNode, valueNode, binaryNode) {
if (isProcessEnvNodeEnv(envNode)) {
// Must compare with literal 'production'
if (!isLiteral(valueNode, 'production')) {
context.report({
node: binaryNode,
messageId: 'invalidComparison',
data: {
comparedValue: valueNode.type === 'Literal' ? String(valueNode.value) : 'non-literal',
},
});
}
}
}

return {
BinaryExpression(node) {
// Check if this is a comparison with === or !==
if (node.operator === '===' || node.operator === '!==') {
checkGuard(node.left, node.right, node);
checkGuard(node.right, node.left, node);
}
},
// Catch any other usage of process.env.NODE_ENV (not in a valid binary expression)
MemberExpression(node) {
if (isProcessEnvNodeEnv(node)) {
// Check if it's part of a valid binary expression
const parent = node.parent;
if (
parent &&
parent.type === 'BinaryExpression' &&
(parent.operator === '===' || parent.operator === '!==')
) {
// This is handled by BinaryExpression visitor
return;
}

// Invalid usage
context.report({
node,
messageId: 'invalidUsage',
});
}
},
};
},
};

export default rule;
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import eslint from 'eslint';
import parser from '@typescript-eslint/parser';
import rule from './consistent-production-guard.mjs';

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

ruleTester.run('consistent-production-guard', rule, {
valid: [
// Should pass: Valid !== comparison with 'production'
{
code: `
if (process.env.NODE_ENV !== 'production') {
console.log('dev');
}
`,
},
// Should pass: Valid === comparison with 'production'
{
code: `
if (process.env.NODE_ENV === 'production') {
console.log('prod');
}
`,
},
// Should pass: Reversed comparison (literal on left)
{
code: `
if ('production' !== process.env.NODE_ENV) {
console.log('dev');
}
`,
},
// Should pass: Reversed comparison with ===
{
code: `
if ('production' === process.env.NODE_ENV) {
console.log('prod');
}
`,
},
// Should pass: Code without process.env.NODE_ENV
{
code: `
const foo = 'bar';
if (foo === 'baz') {
console.log('test');
}
`,
},
],
invalid: [
// Should fail: Comparing with 'development'
{
code: `
if (process.env.NODE_ENV === 'development') {
console.log('dev');
}
`,
errors: [
{
messageId: 'invalidComparison',
data: { comparedValue: 'development' },
},
],
},
// Should fail: Comparing with 'test'
{
code: `
if (process.env.NODE_ENV !== 'test') {
console.log('not test');
}
`,
errors: [
{
messageId: 'invalidComparison',
data: { comparedValue: 'test' },
},
],
},
// Should fail: Reversed comparison with 'development'
{
code: `
if ('development' === process.env.NODE_ENV) {
console.log('dev');
}
`,
errors: [
{
messageId: 'invalidComparison',
data: { comparedValue: 'development' },
},
],
},
// Should fail: Non-static comparison (variable)
{
code: `
const env = 'production';
if (process.env.NODE_ENV !== env) {
console.log('check');
}
`,
errors: [
{
messageId: 'invalidComparison',
data: { comparedValue: 'non-literal' },
},
],
},
// Should fail: Non-static comparison (reversed)
{
code: `
const env = 'production';
if (env === process.env.NODE_ENV) {
console.log('check');
}
`,
errors: [
{
messageId: 'invalidComparison',
data: { comparedValue: 'non-literal' },
},
],
},
// Should fail: Invalid usage (function call)
{
code: `
foo(process.env.NODE_ENV);
`,
errors: [
{
messageId: 'invalidUsage',
},
],
},
// Should fail: Invalid usage (assignment)
{
code: `
const env = process.env.NODE_ENV;
`,
errors: [
{
messageId: 'invalidUsage',
},
],
},
// Should fail: Invalid usage (template literal)
{
code: `
const message = \`Environment: \${process.env.NODE_ENV}\`;
`,
errors: [
{
messageId: 'invalidUsage',
},
],
},
],
});
31 changes: 31 additions & 0 deletions packages/code-infra/src/eslint/material-ui/rules/nodeEnvUtils.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Shared utilities for ESLint rules dealing with process.env.NODE_ENV
*/

/**
* Checks if a node is process.env.NODE_ENV
* @param {import('estree').Node} node
* @returns {boolean}
*/
export 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'
);
}

/**
* Checks if a node is a Literal with a specific value
* @param {import('estree').Node} node
* @param {string} value
* @returns {boolean}
*/
export function isLiteral(node, value) {
return node.type === 'Literal' && node.value === value;
}
Loading