Skip to content

Commit

Permalink
feat(eslint-rules): implement no-restricted-globals type-aware rule (
Browse files Browse the repository at this point in the history
  • Loading branch information
Hotell authored Oct 3, 2024
1 parent 5bea3fa commit 9636aa9
Show file tree
Hide file tree
Showing 6 changed files with 279 additions and 45 deletions.
19 changes: 10 additions & 9 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion tools/eslint-rules/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -27,5 +28,5 @@ module.exports = {
* [myCustomRuleName]: myCustomRule
* }
*/
rules: { [consistentCallbackTypeName]: consistentCallbackType },
rules: { [consistentCallbackTypeName]: consistentCallbackType, [noRestrictedGlobalsName]: noRestrictedGlobals },
};
90 changes: 90 additions & 0 deletions tools/eslint-rules/rules/no-restricted-globals.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout> | undefined = undefined;`,
},
{
options: ['ResizeObserver'],
code: `const resizeObserverRef = React.useRef<ResizeObserver | null>(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<typeof setTimeout> | 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 },
],
},
],
});
163 changes: 163 additions & 0 deletions tools/eslint-rules/rules/no-restricted-globals.ts
Original file line number Diff line number Diff line change
@@ -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)<Options, MessageIds>({
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<Record<string, string | null>>((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>()` --> `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);
}
});
},
};
},
});
6 changes: 5 additions & 1 deletion tools/eslint-rules/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "ESNext"
"module": "ESNext",
"moduleResolution": "NodeNext",
"target": "ES2022",
"lib": ["ES2022"],
"esModuleInterop": true
},
"files": [],
"include": [],
Expand Down
Loading

0 comments on commit 9636aa9

Please sign in to comment.