From 9636aa941221c3f124f14e99939fe2d94312a086 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 3 Oct 2024 16:12:52 +0200 Subject: [PATCH] feat(eslint-rules): implement `no-restricted-globals` type-aware rule (#32862) --- package.json | 19 +- tools/eslint-rules/index.ts | 3 +- .../rules/no-restricted-globals.spec.ts | 90 ++++++++++ .../rules/no-restricted-globals.ts | 163 ++++++++++++++++++ tools/eslint-rules/tsconfig.json | 6 +- yarn.lock | 43 +---- 6 files changed, 279 insertions(+), 45 deletions(-) create mode 100644 tools/eslint-rules/rules/no-restricted-globals.spec.ts create mode 100644 tools/eslint-rules/rules/no-restricted-globals.ts diff --git a/package.json b/package.json index e64c9da8b019c..3cf868025b6b6 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,6 @@ "@types/chrome-remote-interface": "0.30.0", "@types/circular-dependency-plugin": "5.0.8", "@types/copy-webpack-plugin": "10.1.0", - "@types/doctrine": "0.0.5", "@types/d3-array": "3.2.1", "@types/d3-axis": "3.0.6", "@types/d3-format": "3.0.4", @@ -128,8 +127,9 @@ "@types/d3-scale": "4.0.8", "@types/d3-selection": "3.0.10", "@types/d3-shape": "3.1.6", - "@types/d3-time-format": "3.0.4", "@types/d3-time": "3.0.3", + "@types/d3-time-format": "3.0.4", + "@types/doctrine": "0.0.5", "@types/ejs": "3.1.2", "@types/enzyme": "3.10.7", "@types/eslint": "8.56.10", @@ -200,12 +200,6 @@ "css-loader": "5.0.1", "cypress": "13.6.4", "cypress-real-events": "1.11.0", - "danger": "^11.0.0", - "dedent": "1.2.0", - "del": "6.0.0", - "doctoc": "2.0.1", - "doctrine": "3.0.0", - "dotparser": "1.1.1", "d3-array": "3.2.4", "d3-axis": "3.0.0", "d3-format": "3.1.0", @@ -214,8 +208,14 @@ "d3-scale": "4.0.2", "d3-selection": "3.0.0", "d3-shape": "3.2.0", - "d3-time-format": "3.0.0", "d3-time": "3.1.0", + "d3-time-format": "3.0.0", + "danger": "^11.0.0", + "dedent": "1.2.0", + "del": "6.0.0", + "doctoc": "2.0.1", + "doctrine": "3.0.0", + "dotparser": "1.1.1", "ejs": "3.1.10", "embla-carousel": "8.1.8", "embla-carousel-autoplay": "8.1.8", @@ -246,6 +246,7 @@ "fork-ts-checker-webpack-plugin": "9.0.2", "fs-extra": "8.1.0", "glob": "7.2.0", + "globals": "13.24.0", "graphviz": "0.0.9", "gulp": "4.0.2", "gulp-babel": "8.0.0", diff --git a/tools/eslint-rules/index.ts b/tools/eslint-rules/index.ts index e3e602fdf24ca..90a2859066fdb 100644 --- a/tools/eslint-rules/index.ts +++ b/tools/eslint-rules/index.ts @@ -1,3 +1,4 @@ +import { RULE_NAME as noRestrictedGlobalsName, rule as noRestrictedGlobals } from './rules/no-restricted-globals'; import { RULE_NAME as consistentCallbackTypeName, rule as consistentCallbackType, @@ -27,5 +28,5 @@ module.exports = { * [myCustomRuleName]: myCustomRule * } */ - rules: { [consistentCallbackTypeName]: consistentCallbackType }, + rules: { [consistentCallbackTypeName]: consistentCallbackType, [noRestrictedGlobalsName]: noRestrictedGlobals }, }; diff --git a/tools/eslint-rules/rules/no-restricted-globals.spec.ts b/tools/eslint-rules/rules/no-restricted-globals.spec.ts new file mode 100644 index 0000000000000..f6360f09e1d30 --- /dev/null +++ b/tools/eslint-rules/rules/no-restricted-globals.spec.ts @@ -0,0 +1,90 @@ +import globals from 'globals'; +import { RuleTester } from '@typescript-eslint/rule-tester'; +import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import { rule, RULE_NAME } from './no-restricted-globals'; + +const ruleTester = new RuleTester(); + +ruleTester.run(RULE_NAME, rule, { + valid: [ + { + code: 'foo.bar', + options: ['bar'], + }, + { + options: ['window'], + code: `let ev = new KeyboardEvent('keydown');`, + }, + { + code: 'event', + options: ['foo'], + globals: globals.browser, + }, + { options: ['KeyboardEvent'], code: `let ev: KeyboardEvent;` }, + { + options: ['setTimeout'], + code: `let timerID: ReturnType | undefined = undefined;`, + }, + { + options: ['ResizeObserver'], + code: `const resizeObserverRef = React.useRef(null);`, + }, + ], + invalid: [ + { + code: 'bar', + options: ['bar'], + errors: [{ messageId: 'defaultMessage' }], + }, + { + code: `let ev = new KeyboardEvent('keydown');`, + options: ['KeyboardEvent'], + errors: [{ messageId: 'defaultMessage', data: { name: 'KeyboardEvent' } }], + }, + { + code: 'event', + options: ['foo', 'event'], + globals: globals.browser, + errors: [ + { + messageId: 'defaultMessage', + data: { name: 'event' }, + type: AST_NODE_TYPES.Identifier, + }, + ], + }, + { + options: ['setTimeout'], + code: `let timerID = setTimeout(()=>{},0);`, + errors: [{ messageId: 'defaultMessage' }], + }, + { + options: ['setTimeout'], + code: ` + let timerID = setTimeout(()=>{},0); + + let futureSetTimerId: ReturnType | undefined = undefined; + `, + errors: [{ messageId: 'defaultMessage', data: { name: 'setTimeout' }, type: AST_NODE_TYPES.Identifier, line: 2 }], + }, + { + options: ['ResizeObserver'], + code: `const resizeObserverRef = new ResizeObserver((entries,observer)=>{ return; });`, + errors: [{ messageId: 'defaultMessage' }], + }, + // assert usage if both as value and as type are used within same scope + { + options: ['ResizeObserver'], + code: ` + let roInstance: ResizeObserver; + + const resizeObserverRef = new ResizeObserver((entries,observer)=>{ return; }); + + console.log(roInstance); + `, + errors: [ + { messageId: 'defaultMessage', data: { name: 'ResizeObserver' }, type: AST_NODE_TYPES.Identifier, line: 4 }, + ], + }, + ], +}); diff --git a/tools/eslint-rules/rules/no-restricted-globals.ts b/tools/eslint-rules/rules/no-restricted-globals.ts new file mode 100644 index 0000000000000..25e265209820f --- /dev/null +++ b/tools/eslint-rules/rules/no-restricted-globals.ts @@ -0,0 +1,163 @@ +/** + * This file sets you up with structure needed for an ESLint rule. + * + * It leverages utilities from @typescript-eslint to allow TypeScript to + * provide autocompletions etc for the configuration. + * + * Your rule's custom logic will live within the create() method below + * and you can learn more about writing ESLint rules on the official guide: + * + * https://eslint.org/docs/developer-guide/working-with-rules + * + * You can also view many examples of existing rules here: + * + * https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin/src/rules + */ + +import { Reference } from '@typescript-eslint/scope-manager'; +import { ESLintUtils, AST_NODE_TYPES } from '@typescript-eslint/utils'; + +// NOTE: The rule will be available in ESLint configs as "@nx/workspace-no-restricted-globals" +export const RULE_NAME = 'no-restricted-globals'; + +type MessageIds = 'defaultMessage' | 'customMessage'; + +type Options = Array<{ name: string; message?: string } | string>; + +export const rule = ESLintUtils.RuleCreator(() => __filename)({ + name: RULE_NAME, + meta: { + type: 'problem', + docs: { + description: ``, + recommended: 'recommended', + }, + schema: { + type: 'array', + items: { + oneOf: [ + { + type: 'string', + }, + { + type: 'object', + properties: { + name: { type: 'string' }, + message: { type: 'string' }, + }, + required: ['name'], + additionalProperties: false, + }, + ], + }, + uniqueItems: true, + minItems: 0, + }, + + messages: { + defaultMessage: "Unexpected use of '{{name}}'.", + customMessage: "Unexpected use of '{{name}}'. {{customMessage}}", + }, + }, + defaultOptions: [], + create(context, options) { + const sourceCode = context.sourceCode; + + // If no globals are restricted, we don't need to do anything + if (context.options.length === 0) { + return {}; + } + + const restrictedGlobalMessages = context.options.reduce>((acc, option) => { + if (typeof option === 'string') { + acc[option] = null; + return acc; + } + + acc[option.name] = option.message ?? null; + + return acc; + }, {}); + + /** + * Report a variable to be used as a restricted global. + * @param reference the variable reference + * @returns + * @private + */ + function reportReference(reference: Reference) { + const name = reference.identifier.name; + const customMessage = restrictedGlobalMessages[name]; + const messageId = customMessage ? 'customMessage' : 'defaultMessage'; + + context.report({ + node: reference.identifier, + messageId, + data: { + name, + customMessage, + }, + }); + } + + /** + * Check if the given name is a restricted global name. + * @param name name of a variable + * @returns whether the variable is a restricted global or not + */ + function isRestricted(name: string): boolean { + return Object.hasOwn(restrictedGlobalMessages, name); + } + + /** + * Determines if global reference is a TypeScript type ( which is ignored as it doesn't have any impact on runtime) + * @param reference + * @returns + */ + function isTypeReference(reference: Reference) { + if (reference.isTypeReference) { + return true; + } + // eg `let id: typeof setTimeout` --> `typeof setTimeout === TSTypeQuery` + if (reference.identifier.parent.type === AST_NODE_TYPES.TSTypeQuery) { + return true; + } + // eg `useRef()` --> `ResizeObserver === TSTypeReference` + if (reference.identifier.parent.type === AST_NODE_TYPES.TSTypeReference) { + return true; + } + + return false; + } + + return { + Program(node) { + const scope = sourceCode.getScope(node); + + // Report variables declared elsewhere (ex: variables defined as "global" by eslint) + scope.variables.forEach(variable => { + if (!variable.defs.length && isRestricted(variable.name)) { + variable.references.forEach(reference => { + if (isTypeReference(reference)) { + return; + } + + return reportReference(reference); + }); + } + }); + + // Report variables not declared at all + scope.through.forEach(reference => { + if (isTypeReference(reference)) { + return; + } + + if (isRestricted(reference.identifier.name)) { + return reportReference(reference); + } + }); + }, + }; + }, +}); diff --git a/tools/eslint-rules/tsconfig.json b/tools/eslint-rules/tsconfig.json index 6205fc3437dec..7c81409b02835 100644 --- a/tools/eslint-rules/tsconfig.json +++ b/tools/eslint-rules/tsconfig.json @@ -1,7 +1,11 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "module": "ESNext" + "module": "ESNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "lib": ["ES2022"], + "esModuleInterop": true }, "files": [], "include": [], diff --git a/yarn.lock b/yarn.lock index 7d6ab50361cd4..2ffcef7a0670a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12689,18 +12689,18 @@ global@^4.3.0, global@^4.3.1: min-document "^2.19.0" process "^0.11.10" -globals@^11.1.0: - version "11.12.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" - integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== - -globals@^13.19.0: +globals@13.24.0, globals@^13.19.0: version "13.24.0" resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171" integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== dependencies: type-fest "^0.20.2" +globals@^11.1.0: + version "11.12.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" + integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== + globals@^15.9.0: version "15.9.0" resolved "https://registry.yarnpkg.com/globals/-/globals-15.9.0.tgz#e9de01771091ffbc37db5714dab484f9f69ff399" @@ -21522,7 +21522,7 @@ string-length@^5.0.1: char-regex "^2.0.0" strip-ansi "^7.0.1" -"string-width-cjs@npm:string-width@^4.2.0": +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -21557,15 +21557,6 @@ string-width@^3.0.0, string-width@^3.1.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -21666,7 +21657,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -21701,13 +21692,6 @@ strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: dependencies: ansi-regex "^4.1.0" -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-ansi@^7.0.1, strip-ansi@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -23952,7 +23936,7 @@ workspace-tools@^0.27.0: js-yaml "^4.1.0" micromatch "^4.0.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -23987,15 +23971,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"