Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
84 changes: 84 additions & 0 deletions .eslint-plugin-local/code-no-bracket-notation-for-identifiers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as eslint from 'eslint';
import { TSESTree } from '@typescript-eslint/utils';

/**
* Disallow bracket notation for accessing properties that are valid identifiers,
* especially private members (starting with underscore). Bracket notation should
* only be used for properties with special characters or computed property names.
*
* Bad: obj['_privateMember']
* Bad: obj['normalProperty']
* Good: obj._privateMember // TypeScript will catch private access
* Good: obj.normalProperty
* Good: obj['property-with-dashes']
* Good: obj[computedKey]
*/
export = new class NoBracketNotationForIdentifiers implements eslint.Rule.RuleModule {

readonly meta: eslint.Rule.RuleMetaData = {
type: 'problem',
docs: {
description: 'Disallow bracket notation for accessing properties that are valid identifiers'
},
messages: {
noBracketNotation: 'Use dot notation instead of bracket notation for property \'{{property}}\'. Bracket notation bypasses TypeScript\'s type checking and access modifiers.'
},
schema: [],
fixable: 'code'
};

create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener {

/**
* Check if a string is a valid JavaScript identifier
*/
function isValidIdentifier(str: string): boolean {
// Check if it's a valid JavaScript identifier
// Must start with letter, underscore, or dollar sign
// Can contain letters, digits, underscores, or dollar signs
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(str);
Comment on lines +41 to +44
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

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

The regular expression doesn't account for reserved JavaScript keywords. Properties like 'class', 'const', 'function', etc. are valid in bracket notation but cannot be used with dot notation. Add a check to exclude reserved keywords: const reservedWords = new Set(['break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'export', 'extends', 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof', 'new', 'return', 'super', 'switch', 'this', 'throw', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield', 'let', 'static', 'enum', 'await', 'implements', 'interface', 'package', 'private', 'protected', 'public']); return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(str) && !reservedWords.has(str);

Suggested change
// Check if it's a valid JavaScript identifier
// Must start with letter, underscore, or dollar sign
// Can contain letters, digits, underscores, or dollar signs
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(str);
// Check if it's a valid JavaScript identifier and not a reserved word
// Must start with letter, underscore, or dollar sign
// Can contain letters, digits, underscores, or dollar signs
const reservedWords = new Set([
'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else',
'export', 'extends', 'finally', 'for', 'function', 'if', 'import', 'in', 'instanceof', 'new', 'return',
'super', 'switch', 'this', 'throw', 'try', 'typeof', 'var', 'void', 'while', 'with', 'yield', 'let',
'static', 'enum', 'await', 'implements', 'interface', 'package', 'private', 'protected', 'public'
]);
return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(str) && !reservedWords.has(str);

Copilot uses AI. Check for mistakes.
}

return {
MemberExpression(node: any) {
const memberExpr = node as TSESTree.MemberExpression;

// Only check computed member expressions (bracket notation)
if (!memberExpr.computed) {
return;
}

// Only check string literals in brackets
if (memberExpr.property.type !== 'Literal' || typeof memberExpr.property.value !== 'string') {
return;
}

const propertyName = memberExpr.property.value;

// If it's a valid identifier, report it
if (isValidIdentifier(propertyName)) {
context.report({
node: memberExpr.property,
messageId: 'noBracketNotation',
data: {
property: propertyName
},
fix(fixer) {
// Convert obj['property'] to obj.property
// We need to replace the ['property'] part with .property
const sourceCode = context.getSourceCode();
const objectText = sourceCode.getText(memberExpr.object as unknown as eslint.Rule.Node);
const dotNotation = `${objectText}.${propertyName}`;
return fixer.replaceText(node, dotNotation);
Comment on lines +73 to +77
Copy link

Copilot AI Oct 31, 2025

Choose a reason for hiding this comment

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

The fix implementation replaces the entire MemberExpression node with reconstructed text. This approach fails to preserve whitespace, comments, and complex expressions properly. It should use fixer.replaceTextRange targeting only the bracket portion (from the opening bracket to the closing bracket) and replacing it with .${propertyName}. For example, replace the range from the start of '[' to the end of ']' instead of the entire node.

Suggested change
// We need to replace the ['property'] part with .property
const sourceCode = context.getSourceCode();
const objectText = sourceCode.getText(memberExpr.object as unknown as eslint.Rule.Node);
const dotNotation = `${objectText}.${propertyName}`;
return fixer.replaceText(node, dotNotation);
// Only replace the ['property'] part with .property, preserving whitespace/comments
const sourceCode = context.getSourceCode();
const leftBracket = sourceCode.getTokenBefore(memberExpr.property, token => token.value === '[');
const rightBracket = sourceCode.getTokenAfter(memberExpr.property, token => token.value === ']');
if (!leftBracket || !rightBracket) {
return null;
}
return fixer.replaceTextRange(
[leftBracket.range[0], rightBracket.range[1]],
'.' + propertyName
);

Copilot uses AI. Check for mistakes.
}
});
}
}
};
}
};
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export default tseslint.config(
'semi': 'off',
'local/code-translation-remind': 'warn',
'local/code-no-native-private': 'warn',
'local/code-no-bracket-notation-for-identifiers': 'warn',
'local/code-parameter-properties-must-have-explicit-accessibility': 'warn',
'local/code-no-nls-in-standalone-editor': 'warn',
'local/code-no-potentially-unsafe-disposables': 'warn',
Expand Down
Loading