eslint-plugin-ember
Version:
ESLint plugin for Ember.js apps
863 lines (770 loc) • 29.2 kB
JavaScript
;
const { getName, getNodeOrNodeFromVariable } = require('../utils/utils');
const { isClassPropertyOrPropertyDefinition } = require('../utils/types');
const { getImportIdentifier } = require('../utils/import');
const { insertImportDeclaration } = require('../utils/fixer');
const Stack = require('../utils/stack');
const ERROR_MESSAGE = "Don't use Ember's array prototype extensions";
const TOKEN_TYPES = {
PUNCTUATOR: 'Punctuator',
};
const EXTENSION_METHODS = new Set([
/**
* https://api.emberjs.com/ember/release/classes/EmberArray
* EmberArray methods excluding native functions like reduce, filter etc.
* */
'any',
'compact',
'filterBy',
'findBy',
'getEach',
'invoke',
'isAny',
'isEvery',
'mapBy',
'objectAt',
'objectsAt',
'reject',
'rejectBy',
'setEach',
'sortBy',
'toArray',
'uniq',
'uniqBy',
'without',
/**
* https://api.emberjs.com/ember/release/classes/MutableArray
* MutableArray methods excluding `replace`. `replace` is handled differently as it's also part of String.prototype.
* */
'addObject',
'addObjects',
'clear',
'insertAt',
'popObject',
'pushObject',
'pushObjects',
'removeAt',
'removeObject',
'removeObjects',
'reverseObjects',
'setObjects',
'shiftObject',
'unshiftObject',
'unshiftObjects',
]);
const REPLACE_METHOD = 'replace';
/**
* https://api.emberjs.com/ember/release/classes/EmberArray
* EmberArray properties excluding native props: [], length.
* */
const EXTENSION_PROPERTIES = new Set(['lastObject', 'firstObject']);
/**
* Ignore these function calls.
*/
const KNOWN_NON_ARRAY_FUNCTION_CALLS = new Set([
// Promise.reject()
'window.Promise.reject()',
'Promise.reject()',
// RSVP.reject
'RSVP.reject()',
'RSVP.Promise.reject()',
'Ember.RSVP.reject()',
'Ember.RSVP.Promise.reject()',
// Promise.any()
'window.Promise.any()',
'Promise.any()',
// *storage.clear()
'window.localStorage.clear()',
'window.sessionStorage.clear()',
'localStorage.clear()',
'sessionStorage.clear()',
]);
/**
* Ignore these function calls using RegExps.
*/
const KNOWN_NON_ARRAY_FUNCTION_CALLS_REGEXP = new Set([
// ember-cli-mirage
/\.server\.schema\.(.*)\.findBy\(\)/,
]);
/**
* Ignore objects of these names.
*/
const KNOWN_NON_ARRAY_OBJECTS = new Set([
// lodash
'_',
'lodash',
// jquery
'$()',
'jQuery()',
'jquery()',
]);
/**
* Ignore variables initialized to instances of these classes.
*/
const KNOWN_NON_ARRAY_CLASSES = new Set([
// These Set/Map data structure classes have an overlapping clear() method.
'Set',
'Map',
'WeakSet',
'WeakMap',
// https://github.com/tracked-tools/tracked-built-ins
'TrackedSet',
'TrackedMap',
'TrackedWeakSet',
'TrackedWeakMap',
]);
/**
* Ignore certain function calls made on variables containing certain words as they are likely to be instances of non-array classes.
* Words stored in lowercase.
*/
const FN_NAMES_TO_KNOWN_NON_ARRAY_WORDS = new Map([
// These Promise-related objects have an overlapping reject() method.
['reject', new Set(['deferred', 'promise'])],
// These Set/Map data structure classes have an overlapping clear() method.
['clear', new Set(['set', 'map'])],
]);
/**
* Return the (lowercase) list of words in a variable name of various casings (camelCase, PascalCase, snake_case, UPPER_CASE).
* @param {string} name - variable name
* @returns {string[]} list of (lowercase) words in the variable name
*/
function variableNameToWords(name) {
if (name.includes('_')) {
// snake_case, UPPER_CASE.
return name.toLowerCase().trim().split('_');
}
if (name === name.toUpperCase()) {
// Single word.
return [name.toLowerCase()];
}
// camelCase, PascalCase.
return name
.replaceAll(/([A-Z])/g, ' $1')
.toLowerCase()
.trim()
.split(' ');
}
/**
* Returns the fixing object if it can be fixable otherwise returns an empty array.
*
* @param {Object} callExpressionNode The call expression AST node.
* @param {Object} fixer The ESLint fixer object which will be used to apply fixes.
* @param {Object} context The ESlint context object which contains some helper utils
* @param {Object} [options] An object that contains additional information
* @param {String} [options.importedGetName] The name of the imported get specifier from @ember/object package
* @param {String} [options.importedSetName] The name of the imported set specifier from @ember/object package
* @param {String} [options.importedCompareName] The name of the imported compare specifier from @ember/utils package
* @returns {Object|[]}
*/
// eslint-disable-next-line complexity
function applyFix(callExpressionNode, fixer, context, options = {}) {
const calleeProp = callExpressionNode.callee.property;
const propertyName = calleeProp.name;
const calleeObj = callExpressionNode.callee.object;
const callArgs = callExpressionNode.arguments;
const sourceCode = context.getSourceCode();
// Get the open parenthesis immediately after the callee name
const openParenToken = sourceCode.getTokenAfter(calleeProp, {
filter(token) {
return token.type === TOKEN_TYPES.PUNCTUATOR && token.value === '(';
},
});
// Get the close parenthesis from the end of the callExpressionNode
const closeParenToken = sourceCode.getLastToken(callExpressionNode, {
filter(token) {
return token.type === TOKEN_TYPES.PUNCTUATOR && token.value === ')';
},
});
switch (propertyName) {
case 'any': {
return fixer.replaceText(calleeProp, 'some');
}
case 'compact': {
const fixes = [];
if (openParenToken && closeParenToken && callArgs.length === 0) {
fixes.push(
fixer.replaceText(calleeProp, 'filter'),
// Replacing the content starting from open parenthesis to close parenthesis
fixer.replaceTextRange(
[openParenToken.range[0], closeParenToken.range[1]],
'(item => item !== undefined && item !== null)'
)
);
}
return fixes;
}
case 'filterBy':
case 'findBy':
case 'isAny':
case 'isEvery': {
const fixes = [];
if (callArgs.length > 0 && callArgs.length < 3) {
const hasSecondArg = callArgs.length > 1;
const firstArg = callArgs[0];
const secondArg = callArgs[1];
let replacementMethod;
switch (propertyName) {
case 'findBy': {
replacementMethod = 'find';
break;
}
case 'isAny': {
replacementMethod = 'some';
break;
}
case 'isEvery': {
replacementMethod = 'every';
break;
}
default: {
replacementMethod = 'filter';
}
}
// default to `get` if the `get` hasn't already been imported.
const importedGetName = options.importedGetName ?? 'get';
fixes.push(
fixer.replaceText(calleeProp, replacementMethod),
// Replacing the content starting from open parenthesis to close parenthesis
// If the findBy contains two arguments, the property (first argument) value will be compared against the second argument
// If the filterBy contains only one argument, the property's truthy value is used to filter
fixer.replaceTextRange(
[openParenToken.range[0], closeParenToken.range[1]],
`(item => ${importedGetName}(item, ${sourceCode.getText(firstArg)})${
hasSecondArg ? ` === ${sourceCode.getText(secondArg)}` : ''
})`
)
);
// Add `get` import statement only if it is not imported already
if (!options.importedGetName) {
fixes.push(insertImportDeclaration(sourceCode, fixer, '@ember/object', importedGetName));
}
}
return fixes;
}
case 'invoke': {
const fixes = [];
if (callArgs.length > 0) {
const argText = sourceCode.getText(callArgs[0]);
const restOfArgs = callArgs
.slice(1)
.map((arg) => sourceCode.getText(arg))
.join(', ');
// default to `get` if the `get` hasn't already been imported.
const importedGetName = options.importedGetName ?? 'get';
fixes.push(
fixer.replaceText(calleeProp, 'map'),
// Replacing the content starting from open parenthesis to close parenthesis
fixer.replaceTextRange(
[openParenToken.range[0], closeParenToken.range[1]],
`(item => ${importedGetName}(item, ${argText})?.(${restOfArgs}))`
)
);
// Add `get` import statement only if it is not imported already
if (!options.importedGetName) {
fixes.push(insertImportDeclaration(sourceCode, fixer, '@ember/object', importedGetName));
}
}
return fixes;
}
case 'mapBy':
case 'getEach': {
if (callArgs.length === 1) {
const argText = sourceCode.getText(callArgs[0]);
const fixes = [];
// default to `get` if the `get` hasn't already been imported.
const importedGetName = options.importedGetName ?? 'get';
fixes.push(
fixer.replaceText(calleeProp, 'map'),
// Replacing the content starting from open parenthesis to close parenthesis
fixer.replaceTextRange(
[openParenToken.range[0], closeParenToken.range[1]],
`(item => ${importedGetName}(item, ${argText}))`
)
);
// Add `get` import statement only if it is not imported already
if (!options.importedGetName) {
fixes.push(insertImportDeclaration(sourceCode, fixer, '@ember/object', importedGetName));
}
return fixes;
}
return [];
}
case 'objectAt': {
if (callArgs.length === 1) {
return [
fixer.insertTextBefore(
callExpressionNode,
`${sourceCode.getText(calleeObj)}${
callExpressionNode.callee.optional ? '?.' : ''
}[${sourceCode.getText(callArgs[0])}]`
),
fixer.remove(callExpressionNode),
];
}
return [];
}
case 'objectsAt': {
if (callArgs.length > 0) {
return [
fixer.insertTextBefore(
callExpressionNode,
`[${callArgs
.map((arg) => sourceCode.getText(arg))
.join(', ')}].map((ind) => ${sourceCode.getText(calleeObj)}[ind])`
),
fixer.remove(callExpressionNode),
];
}
return [];
}
case 'reject': {
if (callArgs.length > 0 && callArgs.length < 3) {
return [
fixer.replaceText(calleeProp, 'filter'),
// TODO: Switch to `Reflect.apply` once Ember v3 LTS support ends: https://emberjs.com/releases/lts/
fixer.replaceText(
callArgs[0],
`function(...args) { return !(${sourceCode.getText(callArgs[0])}).apply(this, args); }`
),
];
}
return [];
}
case 'rejectBy': {
const fixes = [];
if (callArgs.length > 0 && callArgs.length < 3) {
const hasSecondArg = callArgs.length > 1;
const firstArg = callArgs[0];
const secondArg = callArgs[1];
// default to `get` if the `get` hasn't already been imported.
const importedGetName = options.importedGetName ?? 'get';
fixes.push(
fixer.replaceText(calleeProp, 'filter'),
// Replacing the content starting from open parenthesis to close parenthesis
// If the findBy contains two arguments, the property (first argument) value will be compared against the second argument
// If the filterBy contains only one argument, the property's truthy value is used to filter
fixer.replaceTextRange(
[openParenToken.range[0], closeParenToken.range[1]],
hasSecondArg
? `(item => ${importedGetName}(item, ${sourceCode.getText(
firstArg
)}) !== ${sourceCode.getText(secondArg)})`
: `(item => !${importedGetName}(item, ${sourceCode.getText(firstArg)}))`
)
);
// Add `get` import statement only if it is not imported already
if (!options.importedGetName) {
fixes.push(insertImportDeclaration(sourceCode, fixer, '@ember/object', importedGetName));
}
}
return fixes;
}
case 'setEach': {
const fixes = [];
if (callArgs.length === 2) {
// default to `set` if the `set` hasn't already been imported.
const importedSetName = options.importedSetName ?? 'set';
fixes.push(
fixer.replaceText(calleeProp, 'forEach'),
// Replacing the content starting from open parenthesis to close parenthesis
fixer.replaceTextRange(
[openParenToken.range[0], closeParenToken.range[1]],
`(item => ${importedSetName}(item, ${callArgs
.map((arg) => sourceCode.getText(arg))
.join(', ')}))`
)
);
// Add `set` import statement only if it is not imported already
if (!options.importedSetName) {
fixes.push(insertImportDeclaration(sourceCode, fixer, '@ember/object', importedSetName));
}
return fixes;
}
return [];
}
case 'sortBy': {
const fixes = [];
if (callArgs.length > 0) {
// default to `compare` if the `compare` hasn't already been imported.
const importedCompareName = options.importedCompareName ?? 'compare';
// default to `compare` if the `compare` hasn't already been imported.
const importedGetName = options.importedGetName ?? 'get';
const argsText = callArgs.map((arg) => sourceCode.getText(arg)).join(', ');
let sortFn;
const comparisonFnInString = (key) =>
`${importedCompareName}(${importedGetName}(a, ${key}), ${importedGetName}(b, ${key}))`;
if (callArgs.length === 1 && callArgs[0].type !== 'SpreadElement') {
sortFn =
callArgs[0].type === 'Identifier' || callArgs[0].type === 'Literal'
? `(a, b) => ${comparisonFnInString(argsText)}`
: `(a, b) => {
const key = ${argsText};
return ${comparisonFnInString('key')};
}`;
} else {
// Loop through keys if there are more than one argument
sortFn = `(a, b) => {
for (const key of [${argsText}]) {
const compareValue = ${comparisonFnInString('key')};
if (compareValue) {
return compareValue;
}
}
return 0;
}`;
}
fixes.push(
// Duplicate array
fixer.replaceText(calleeObj, `[...${sourceCode.getText(calleeObj)}]`),
fixer.replaceText(calleeProp, 'sort'),
// Replacing the content starting from open parenthesis to close parenthesis
fixer.replaceTextRange([openParenToken.range[0], closeParenToken.range[1]], `(${sortFn})`)
);
// Add `get` import statement only if it is not imported already
if (!options.importedGetName) {
fixes.push(insertImportDeclaration(sourceCode, fixer, '@ember/object', importedGetName));
}
// Add `compare` import statement only if it is not imported already
if (!options.importedCompareName) {
fixes.push(
insertImportDeclaration(sourceCode, fixer, '@ember/utils', importedCompareName)
);
}
}
return fixes;
}
case 'toArray': {
if (callArgs.length === 0) {
return [
fixer.insertTextBefore(callExpressionNode, `[...${sourceCode.getText(calleeObj)}]`),
fixer.remove(callExpressionNode),
];
}
return [];
}
case 'uniq': {
if (callArgs.length === 0) {
return [
fixer.insertTextBefore(
callExpressionNode,
`[...new Set(${sourceCode.getText(calleeObj)})]`
),
fixer.remove(callExpressionNode),
];
}
return [];
}
case 'uniqBy': {
const fixes = [];
if (callArgs.length === 1) {
const argInString = sourceCode.getText(callArgs[0]);
// default to `get` if the `get` hasn't already been imported.
const importedGetName = options.importedGetName ?? 'get';
let isFunctionArg;
let isLiteralArg;
switch (callArgs[0].type) {
case 'ArrowFunctionExpression':
case 'FunctionExpression': {
isFunctionArg = true;
break;
}
case 'Literal': {
isLiteralArg = true;
break;
}
default: {
break;
}
}
const uniqByFn = `([uniqArr, itemsSet, getterFn], item) => {
const val = getterFn(item);
if (!itemsSet.has(val)) {
itemsSet.add(val);
uniqArr.push(item);
}
return [uniqArr, itemsSet, getterFn];
}`;
let getterFnInText;
if (isLiteralArg) {
getterFnInText = `(item) => ${importedGetName}(item, ${argInString})`;
} else if (isFunctionArg) {
getterFnInText = argInString;
} else {
getterFnInText = `typeof ${argInString} === 'function' ? ${argInString} : (item) => ${importedGetName}(item, ${argInString})`;
}
const reducerInitialValueArr = ['[]', 'new Set()', getterFnInText];
fixes.push(
fixer.replaceText(calleeProp, 'reduce'),
// Replacing the content starting from open parenthesis to close parenthesis
fixer.replaceTextRange(
[openParenToken.range[0], closeParenToken.range[1]],
`(${uniqByFn}, [${reducerInitialValueArr.join(', ')}])[0]`
)
);
// Add `get` import statement only if it is not imported already
if (!options.importedGetName) {
fixes.push(insertImportDeclaration(sourceCode, fixer, '@ember/object', importedGetName));
}
return fixes;
}
return [];
}
case 'without': {
if (callArgs.length === 1) {
const argText = sourceCode.getText(callArgs[0]);
const calleeObjText = sourceCode.getText(calleeObj);
return [
// As per https://api.emberjs.com/ember/release/classes/EmberArray/methods/mapBy?anchor=without
// when the passed value doesn't not exist in the array, it returns the original array
// Hence we first check for the existence of the passed value in the array before calling filter on array
// Used indexOf instead of includes since v3.x of ember is committed to supporting IE11
// as per the ember browser support policy (https://emberjs.com/browser-support/)
// TODO: Switch to includes once Ember v3 LTS support ends: https://emberjs.com/releases/lts/
fixer.replaceText(calleeProp, `indexOf(${argText}) > -1 ? ${calleeObjText}.filter`),
fixer.insertTextAfter(closeParenToken, ` : ${calleeObjText})`),
// Replacing the content starting from open parenthesis to close parenthesis
fixer.replaceTextRange(
[openParenToken.range[0], closeParenToken.range[1]],
`(item => item !== ${argText})`
),
fixer.insertTextBefore(callExpressionNode, '('),
];
}
return [];
}
default: {
return [];
}
}
}
/**
* Check for a call on `this.store` which we can assume is the Ember Data store service.
* We don't check for an initialization as the service could be implicitly injected: https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/no-implicit-injections.md
*/
function isThisStoreCall(node) {
return (
node.type === 'CallExpression' &&
node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'MemberExpression' &&
node.callee.object.object.type === 'ThisExpression' &&
node.callee.object.property.type === 'Identifier' &&
node.callee.object.property.name === 'store' &&
node.callee.property.type === 'Identifier' // Any function call on the store service.
);
}
//----------------------------------------------------------------------------------------------
// General rule - Don't use Ember's array prototype extensions like .any(), .pushObject() or .firstObject
//----------------------------------------------------------------------------------------------
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'suggestion',
docs: {
description: "disallow usage of Ember's `Array` prototype extensions",
category: 'Deprecations',
recommended: false,
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/no-array-prototype-extensions.md',
},
fixable: 'code',
schema: [],
messages: {
main: ERROR_MESSAGE,
},
},
create(context) {
const sourceCode = context.getSourceCode();
const { scopeManager } = sourceCode;
let importedGetName;
let importedSetName;
let importedCompareName;
let importedEmberArrayName;
// Track some information about the current class we're inside.
const classStack = new Stack();
return {
ImportDeclaration(node) {
if (node.source.value === '@ember/object') {
importedGetName = importedGetName || getImportIdentifier(node, '@ember/object', 'get');
importedSetName = importedSetName || getImportIdentifier(node, '@ember/object', 'set');
}
if (node.source.value === '@ember/utils') {
importedCompareName =
importedCompareName || getImportIdentifier(node, '@ember/utils', 'compare');
}
if (node.source.value === '@ember/array') {
importedEmberArrayName =
importedEmberArrayName || getImportIdentifier(node, '@ember/array', 'A');
}
},
/**
* Cover cases when `EXTENSION_METHODS` is getting called.
* Example: something.filterBy();
* @param {Object} node
*/
// eslint-disable-next-line complexity
CallExpression(node) {
// Skip case: filterBy();
if (node.callee.type !== 'MemberExpression') {
return;
}
// Skip case: this.filterBy(); super.filterBy();
if (['ThisExpression', 'Super'].includes(node.callee.object.type)) {
return;
}
if (node.callee.property.type !== 'Identifier') {
return;
}
const name = getName(node, true);
const nameParts = name.split('.');
if (
KNOWN_NON_ARRAY_FUNCTION_CALLS.has(name) ||
KNOWN_NON_ARRAY_OBJECTS.has(nameParts.at(-2))
) {
// Ignore any known non-array objects/function calls to reduce false positives.
return;
}
for (const expression of KNOWN_NON_ARRAY_FUNCTION_CALLS_REGEXP) {
// Ignore any known non-array function calls matching a regular expression to reduce false positives.
if (expression.test(name)) {
return;
}
}
for (const functionName of FN_NAMES_TO_KNOWN_NON_ARRAY_WORDS.keys()) {
const words = FN_NAMES_TO_KNOWN_NON_ARRAY_WORDS.get(functionName);
if (
nameParts.at(-1) === `${functionName}()` &&
variableNameToWords(nameParts.at(-2)).some((word) => words.has(word))
) {
// We found a function call on a variable whose name contains a word that indicates this variable is not an array.
return;
}
}
const nodeInitializedTo = getNodeOrNodeFromVariable(node.callee.object, scopeManager);
if (
nodeInitializedTo.type === 'NewExpression' &&
nodeInitializedTo.callee.type === 'Identifier' &&
KNOWN_NON_ARRAY_CLASSES.has(nodeInitializedTo.callee.name)
) {
// Ignore when we can tell the variable was initialized to an instance of a non-array class.
// Example: const foo = new Set();
return;
}
if (
(nodeInitializedTo.type === 'AwaitExpression' &&
isThisStoreCall(nodeInitializedTo.argument)) ||
isThisStoreCall(nodeInitializedTo)
) {
// Found call on the Ember Data this.store class.
return;
}
if (
node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'MemberExpression' &&
node.callee.object.object.type === 'ThisExpression' &&
['Identifier', 'PrivateIdentifier', 'PrivateName'].includes(
node.callee.object.property.type
) &&
classStack.peek() &&
classStack.peek().classPropertiesToIgnore.has(
node.callee.object.property.type === 'Identifier'
? node.callee.object.property.name
: `#${node.callee.object.property.name}` // Add # for private properties to avoid confusing public/private properties.
)
) {
// Ignore when we can tell the class property was initialized to an instance of a non-array class.
// Example:
/*
class MyClass {
foo = new Set();
myFunc() { this.foo.clear() }
}
*/
return;
}
// Direct usage of `@ember/array` is allowed.
if (
node.type === 'CallExpression' &&
importedEmberArrayName &&
nodeInitializedTo.type === 'CallExpression' &&
nodeInitializedTo.callee.type === 'Identifier' &&
importedEmberArrayName === nodeInitializedTo.callee.name
) {
return;
}
if (EXTENSION_METHODS.has(node.callee.property.name)) {
context.report({
node,
messageId: 'main',
fix(fixer) {
return applyFix(node, fixer, context, {
importedCompareName,
importedGetName,
importedSetName,
});
},
});
}
// Example: someArray.replace(1, 2, [1, 2, 3]);
// We can differentiate String.prototype.replace and Array.prototype.replace by arguments length
// String.prototype.replace can only have 2 arguments, Array.prototype.replace needs to have exact 3 arguments
if (node.callee.property.name === REPLACE_METHOD && node.arguments.length === 3) {
context.report({ node, messageId: 'main' });
}
},
/**
* Cover cases when `EXTENSION_PROPERTIES` is accessed like:
* foo.firstObject;
* bar.lastObject.bar;
* @param {Object} node
*/
MemberExpression(node) {
// Skip case when EXTENSION_PROPERTIES is accessed through callee.
// Example: something.firstObject()
if (node.parent.type === 'CallExpression') {
return;
}
if (node.property.type !== 'Identifier') {
return;
}
if (EXTENSION_PROPERTIES.has(node.property.name)) {
context.report({ node, messageId: 'main' });
}
},
/**
* Cover cases when `EXTENSION_PROPERTIES` is accessed through literals like:
* get(something, 'foo.firstObject');
* set(something, 'lastObject.bar', 'something');
* @param {Object} node
*/
Literal(node) {
// Generate regexp for extension properties.
// new RegExp(`${[...EXTENSION_PROPERTIES].map(prop => `(\.|^)${prop}(\.|$)`).join('|')}`) won't generate \. correctly
const regexp = /(\.|^)firstObject(\.|$)|(\.|^)lastObject(\.|$)/;
if (typeof node.value === 'string' && regexp.test(node.value)) {
context.report({ node, messageId: 'main' });
}
},
ClassDeclaration(node) {
// Keep track of class properties to ignore because we know they were initialized to an instance of a non-array class.
const classPropertiesToIgnore = new Set(
node.body.body
.filter(
(n) =>
// ClassProperty / ClassPrivateProperty / PrivateName are for ESLint v7.
(isClassPropertyOrPropertyDefinition(n) || n.type === 'ClassPrivateProperty') &&
['Identifier', 'PrivateIdentifier', 'PrivateName'].includes(n.key.type) &&
n.value &&
n.value.type === 'NewExpression' &&
n.value.callee.type === 'Identifier' &&
KNOWN_NON_ARRAY_CLASSES.has(n.value.callee.name)
)
.map((n) => (n.key.type === 'Identifier' ? n.key.name : `#${n.key.name}`)) // Add # for private properties to avoid confusing public/private properties.
);
classStack.push({ node, classPropertiesToIgnore });
},
'ClassDeclaration:exit'() {
// Leaving the current class.
classStack.pop();
},
};
},
};