diff --git a/.gitignore b/.gitignore index 6dfc0f9..3ef78df 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-react jsx-ast-utils -react +/react dist/**/*.js dist/**/*.js.map diff --git a/build.sh b/build.sh index bd7e84b..67f6237 100755 --- a/build.sh +++ b/build.sh @@ -1,9 +1,16 @@ #!/bin/bash -rm dist/*.js -mkdir -p dist +./src/build-local-rules.ts ./esbuild.ts +npx tsc ./src/local/index.ts --outdir ./dist/local --target ESNext --module CommonJS >/dev/null +npx tsc ./src/basic.ts --outdir ./dist --declaration --target ESNext --module CommonJS >/dev/null -# Test -DEST=$HOME/Git/archive/node_modules -rm -rf "$DEST/@proteria/eslint-rules" -cp -r dist "$DEST/@proteria/eslint-rules" +type() { + npx dts-bundle-generator "./eslint-plugin-$1/$2" \ + -o "./dist/$1/index.d.ts" \ + --project "./eslint-plugin-$1/tsconfig.json" \ + --no-check +} + +# type import src/index.js +# type jsx-a11y src/index.js +# type react index.js diff --git a/dist/basic.d.ts b/dist/basic.d.ts new file mode 100644 index 0000000..2c9878f --- /dev/null +++ b/dist/basic.d.ts @@ -0,0 +1,2 @@ +import type { ESLintConfig } from 'eslint-define-config'; +export declare function extendConfig({ plugins, settings, rules, ...config }: ESLintConfig): ESLintConfig; diff --git a/dist/package.json b/dist/package.json index d14dbcc..254d62d 100644 --- a/dist/package.json +++ b/dist/package.json @@ -1,10 +1,13 @@ { "name": "@aet/eslint-rules", - "version": "0.0.1-beta.11", + "version": "0.0.1-beta.16", "license": "UNLICENSED", "peerDependencies": { "typescript": "^5.1.6" }, + "bin": { + "eslint-add-alias": "./addAlias.js" + }, "dependencies": { "@types/eslint": "^8.44.0", "aria-query": "^5.3.0", @@ -17,6 +20,7 @@ "eslint-import-resolver-node": "^0.3.7", "eslint-module-utils": "^2.8.0", "estraverse": "^5.3.0", + "lodash": "^4.17.21", "is-core-module": "^2.12.1", "is-glob": "^4.0.3", "language-tags": "^1.0.8", diff --git a/dist/react-hooks/index.d.ts b/dist/react-hooks/index.d.ts new file mode 100644 index 0000000..6aa35e9 --- /dev/null +++ b/dist/react-hooks/index.d.ts @@ -0,0 +1,12 @@ +import type { Linter, Rule } from 'eslint'; + +export const __EXPERIMENTAL__: false; + +export const configs: { + recommended: Linter.BaseConfig; +}; + +export const rules: { + 'rules-of-hooks': Rule.RuleModule; + 'exhaustive-deps': Rule.RuleModule; +}; diff --git a/esbuild.ts b/esbuild.ts index 03242c5..35bdb48 100755 --- a/esbuild.ts +++ b/esbuild.ts @@ -1,14 +1,212 @@ #!/usr/bin/env -S node -r esbin +import assert from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { resolve, extname, relative } from 'node:path'; +import { isBuiltin } from 'node:module'; import esbuild from 'esbuild'; -import { resolve } from 'path'; -import { babelPlugin } from './src/babel'; +import type { Loader, Plugin } from 'esbuild'; +import * as babel from '@babel/core'; +import { memoize } from 'lodash'; +import { gray, green } from 'picocolors'; +import type { types as t } from '@babel/core'; +import { dependencies } from './dist/package.json'; +import { createMacro, type MacroHandler } from 'babel-plugin-macros'; const args = process.argv.slice(2); const ENV = process.env.NODE_ENV || 'development'; const PROD = ENV === 'production'; +class HandlerMap { + map = new Map(); + + set(names: string | string[], handler: MacroHandler) { + names = Array.isArray(names) ? names : [names]; + const macro = createMacro(handler); + for (const name of names) { + this.map.set(name, macro); + } + return this; + } + + get keys() { + return Array.from(this.map.keys()); + } + + resolvePath = (module: string) => module; + require = (module: string) => this.map.get(module); + isMacrosName = (module: string) => this.map.has(module); +} + +const map = new HandlerMap() + .set( + 'object.assign', + replace(t => t.memberExpression(t.identifier('Object'), t.identifier('assign'))), + ) + .set( + ['object-values', 'object.values'], + replace(t => t.memberExpression(t.identifier('Object'), t.identifier('values'))), + ) + .set( + 'object.fromentries', + replace(t => t.memberExpression(t.identifier('Object'), t.identifier('fromEntries'))), + ) + .set( + 'object.entries', + replace(t => t.memberExpression(t.identifier('Object'), t.identifier('entries'))), + ) + .set( + 'has', + replace(t => t.memberExpression(t.identifier('Object'), t.identifier('hasOwn'))), + ) + .set( + 'array-includes', + proto(t => t.identifier('includes')), + ) + .set( + 'array.prototype.flatmap', + proto(t => t.identifier('flatMap')), + ) + .set( + 'array.prototype.flat', + proto(t => t.identifier('flat')), + ) + .set( + 'array.prototype.findlastindex', + proto(t => t.identifier('findLastIndex')), + ) + .set( + 'array.prototype.tosorted', + proto(t => t.identifier('toSorted')), + ) + .set( + 'string.prototype.matchall', + proto(t => t.identifier('matchAll')), + ); + +function replace(getReplacement: (types: typeof t) => t.Expression): MacroHandler { + return ({ references, babel: { types: t } }) => { + references.default.forEach(referencePath => { + referencePath.replaceWith(getReplacement(t)); + }); + }; +} + +function proto(getProperty: (types: typeof t) => t.Expression): MacroHandler { + return ({ references, babel: { types: t } }) => { + references.default.forEach(referencePath => { + const { parent, parentPath } = referencePath; + assert(t.isCallExpression(parent)); + const [callee, ...rest] = parent.arguments; + parentPath!.replaceWith( + t.callExpression( + t.memberExpression(callee as t.Expression, getProperty(t)), + rest, + ), + ); + }); + }; +} + +export const babelPlugin: Plugin = { + name: 'babel', + setup(build) { + const { keys, ...macroOptions } = map; + + build.onLoad({ filter: /\.[jt]sx?$/ }, args => { + const { path } = args; + if (path.includes('node_modules/')) { + return null; + } + + let source = readFileSync(path, 'utf-8') + .replaceAll("require('object.hasown/polyfill')()", 'Object.hasOwn') + .replaceAll("require('object.fromentries/polyfill')()", 'Object.fromEntries'); + + if ( + path.includes('eslint-plugin-import/src/rules/') || + path.includes('eslint-plugin-import/config/') + ) { + source = source.replace('\nmodule.exports = {', '\nexport default {'); + } + + const isFlow = source.includes('@flow'); + const loader = extname(path).slice(1) as Loader; + + if (!isFlow && !keys.some(key => source.includes(key))) { + return { contents: source, loader }; + } + + const res = babel.transformSync(source, { + filename: path, + babelrc: false, + configFile: false, + parserOpts: { + plugins: [isFlow ? 'flow' : 'typescript'], + }, + plugins: [ + isFlow && '@babel/plugin-transform-flow-strip-types', + ['babel-plugin-macros', macroOptions], + ].filter(Boolean), + })!; + + return { + contents: res.code!, + loader, + }; + }); + }, +}; + +declare global { + interface Array { + filter( + predicate: BooleanConstructor, + ): Exclude[]; + } +} + +const log = memoize(console.log); + +const plugins: Plugin[] = [ + babelPlugin, + { + name: 'alias', + setup(build) { + build.onResolve({ filter: /^jsx-ast-utils$/ }, () => ({ + path: resolve('./jsx-ast-utils/src/index.js'), + })); + build.onResolve({ filter: /^jsx-ast-utils\/.+$/ }, ({ path }) => ({ + path: resolve('./jsx-ast-utils/', path.slice('jsx-ast-utils/'.length)) + '.js', + })); + }, + }, +]; +if (process.env.DEBUG) { + plugins.push({ + name: 'deps-check', + setup(build) { + const declared = new Set(Object.keys(dependencies)); + + build.onResolve({ filter: /^.*$/ }, ({ path, importer }) => { + if ( + !path.startsWith('./') && + !path.startsWith('../') && + !isBuiltin(path) && + path !== 'eslint' && + !path.startsWith('eslint/') && + !path.startsWith('eslint-module-utils/') && + !declared.has(path) + ) { + log(green(path), gray('from'), './' + relative(process.cwd(), importer)); + } + return null; + }); + }, + }); +} + async function main(entry: string, outfile: string) { - const context = await esbuild.context({ + await esbuild.build({ entryPoints: [entry], outfile, bundle: true, @@ -16,37 +214,18 @@ async function main(entry: string, outfile: string) { platform: 'node', packages: 'external', sourcemap: 'linked', - plugins: [ - babelPlugin, - { - name: 'alias', - setup(build) { - build.onResolve({ filter: /^jsx-ast-utils$/ }, () => ({ - path: resolve('./jsx-ast-utils/src/index.js'), - })); - build.onResolve({ filter: /^jsx-ast-utils\/.+$/ }, ({ path }) => ({ - path: - resolve('./jsx-ast-utils/', path.slice('jsx-ast-utils/'.length)) + '.js', - })); - }, - }, - ], + plugins, define: {}, banner: { js: '/* eslint-disable */', }, }); - - await context.rebuild(); - - if (args.includes('-w') || args.includes('--watch')) { - await context.watch(); - } else { - await context.dispose(); - } } main('./eslint-plugin-react/index.js', './dist/react/index.js'); main('./eslint-plugin-import/src/index.js', './dist/import/index.js'); main('./eslint-plugin-jsx-a11y/src/index.js', './dist/jsx-a11y/index.js'); -main('./src/ensureRedirect.ts', './dist/ensureRedirect.js'); +main('./eslint-plugin-react-hooks/index.ts', './dist/react-hooks/index.js'); +main('./eslint-plugin-n/lib/index.js', './dist/n/index.js'); +main('./src/addAlias.ts', './dist/addAlias.js'); +main('./src/rules/index.ts', './dist/rules/index.js'); diff --git a/eslint-plugin-react-hooks/ExhaustiveDeps.ts b/eslint-plugin-react-hooks/ExhaustiveDeps.ts index e9c251b..122469d 100644 --- a/eslint-plugin-react-hooks/ExhaustiveDeps.ts +++ b/eslint-plugin-react-hooks/ExhaustiveDeps.ts @@ -6,10 +6,43 @@ */ /* eslint-disable no-for-of-loops/no-for-of-loops */ +import type { Rule, Scope } from 'eslint'; +import type { + FunctionDeclaration, + CallExpression, + Expression, + Super, + Node, + ArrowFunctionExpression, + FunctionExpression, + SpreadElement, + Identifier, + VariableDeclarator, + MemberExpression, + ChainExpression, + Pattern, + OptionalMemberExpression, +} from 'estree'; +import type { FromSchema } from 'json-schema-to-ts'; +import { __EXPERIMENTAL__ } from './index'; -'use strict'; +const schema = { + type: 'object', + additionalProperties: false, + enableDangerousAutofixThisMayCauseInfiniteLoops: false, + properties: { + additionalHooks: { + type: 'string', + }, + enableDangerousAutofixThisMayCauseInfiniteLoops: { + type: 'boolean', + }, + }, +} as const; -export default { +type Config = FromSchema; + +const rule: Rule.RuleModule = { meta: { type: 'suggestion', docs: { @@ -20,41 +53,24 @@ export default { }, fixable: 'code', hasSuggestions: true, - schema: [ - { - type: 'object', - additionalProperties: false, - enableDangerousAutofixThisMayCauseInfiniteLoops: false, - properties: { - additionalHooks: { - type: 'string', - }, - enableDangerousAutofixThisMayCauseInfiniteLoops: { - type: 'boolean', - }, - }, - }, - ], + schema: [schema], }, - create(context) { + create(context): Rule.RuleListener { + const contextOptions = (context.options[0] || {}) as Config; // Parse the `additionalHooks` regex. - const additionalHooks = - context.options && context.options[0] && context.options[0].additionalHooks - ? new RegExp(context.options[0].additionalHooks) - : undefined; + const additionalHooks = contextOptions?.additionalHooks + ? new RegExp(context.options[0].additionalHooks) + : undefined; const enableDangerousAutofixThisMayCauseInfiniteLoops = - (context.options && - context.options[0] && - context.options[0].enableDangerousAutofixThisMayCauseInfiniteLoops) || - false; + contextOptions?.enableDangerousAutofixThisMayCauseInfiniteLoops || false; const options = { additionalHooks, enableDangerousAutofixThisMayCauseInfiniteLoops, }; - function reportProblem(problem) { + function reportProblem(problem: Rule.ReportDescriptor): void { if (enableDangerousAutofixThisMayCauseInfiniteLoops) { // Used to enable legacy behavior. Dangerous. // Keep this as an option until major IDEs upgrade (including VSCode FB ESLint extension). @@ -65,20 +81,23 @@ export default { context.report(problem); } - const scopeManager = context.getSourceCode().scopeManager; + const scopeManager = context.sourceCode.scopeManager; // Should be shared between visitors. - const setStateCallSites = new WeakMap(); - const stateVariables = new WeakSet(); - const stableKnownValueCache = new WeakMap(); - const functionWithoutCapturedValueCache = new WeakMap(); + const setStateCallSites = new WeakMap(); + const stateVariables = new WeakSet(); + const stableKnownValueCache = new WeakMap(); + const functionWithoutCapturedValueCache = new WeakMap(); const useEffectEventVariables = new WeakSet(); - function memoizeWithWeakMap(fn, map) { + function memoizeWithWeakMap( + fn: (v: T) => R, + map: WeakMap, + ): (arg: T) => R { return function (arg) { if (map.has(arg)) { // to verify cache hits: // console.log(arg.name) - return map.get(arg); + return map.get(arg)!; } const result = fn(arg); map.set(arg, result); @@ -89,12 +108,12 @@ export default { * Visitor for both function expressions and arrow function expressions. */ function visitFunctionWithDependencies( - node, - declaredDependenciesNode, - reactiveHook, - reactiveHookName, - isEffect, - ) { + node: ArrowFunctionExpression | FunctionExpression | FunctionDeclaration, + declaredDependenciesNode: SpreadElement | Expression, + reactiveHook: Super | Expression, + reactiveHookName: string, + isEffect: boolean, + ): void { if (isEffect && node.async) { reportProblem({ node: node, @@ -114,7 +133,7 @@ export default { } // Get the current scope. - const scope = scopeManager.acquire(node); + const scope = scopeManager.acquire(node)!; // Find all our "pure scopes". On every re-render of a component these // pure scopes may have changes to the variables declared within. So all @@ -124,8 +143,8 @@ export default { // According to the rules of React you can't read a mutable value in pure // scope. We can't enforce this in a lint so we trust that all variables // declared outside of pure scope are indeed frozen. - const pureScopes = new Set(); - let componentScope = null; + const pureScopes = new Set(); + let componentScope: Scope.Scope; { let currentScope = scope.upper; while (currentScope) { @@ -159,7 +178,7 @@ export default { // const onStuff = useEffectEvent(() => {}) // ^^^ true for this reference // False for everything else. - function isStableKnownHookValue(resolved) { + function isStableKnownHookValue(resolved: Scope.Variable): boolean { if (!isArray(resolved.defs)) { return false; } @@ -171,7 +190,7 @@ export default { if (def.node.type !== 'VariableDeclarator') { return false; } - let init = def.node.init; + let init = (def.node as VariableDeclarator).init; if (init == null) { return false; } @@ -206,10 +225,11 @@ export default { if (init.type !== 'CallExpression') { return false; } - let callee = init.callee; + let callee: Node = init.callee; // Step into `= React.something` initializer. if ( callee.type === 'MemberExpression' && + callee.object.type === 'Identifier' && callee.object.name === 'React' && callee.property != null && !callee.computed @@ -219,14 +239,14 @@ export default { if (callee.type !== 'Identifier') { return false; } - const id = def.node.id; + const id = (def.node as VariableDeclarator).id; const { name } = callee; if (name === 'useRef' && id.type === 'Identifier') { // useRef() return value is stable. return true; } else if (isUseEffectEventIdentifier(callee) && id.type === 'Identifier') { for (const ref of resolved.references) { - if (ref !== id) { + if (ref.identifier !== id) { useEffectEventVariables.add(ref.identifier); } } @@ -251,7 +271,7 @@ export default { if (writeCount > 1) { return false; } - setStateCallSites.set(references[i].identifier, id.elements[0]); + setStateCallSites.set(references[i].identifier, id.elements[0]!); } } // Setter is stable. @@ -286,7 +306,7 @@ export default { } // Some are just functions that don't reference anything dynamic. - function isFunctionWithoutCapturedValues(resolved) { + function isFunctionWithoutCapturedValues(resolved: Scope.Variable): boolean { if (!isArray(resolved.defs)) { return false; } @@ -353,32 +373,33 @@ export default { ); // These are usually mistaken. Collect them. - const currentRefsInEffectCleanup = new Map(); + const currentRefsInEffectCleanup = new Map< + string, + { reference: Scope.Reference; dependencyNode: Identifier } + >(); // Is this reference inside a cleanup function for this effect node? // We can check by traversing scopes upwards from the reference, and checking // if the last "return () => " we encounter is located directly inside the effect. - function isInsideEffectCleanup(reference) { - let curScope = reference.from; + function isInsideEffectCleanup(reference: Scope.Reference): boolean { + let curScope: Scope.Scope = reference.from; let isInReturnedFunction = false; while (curScope.block !== node) { if (curScope.type === 'function') { - isInReturnedFunction = - curScope.block.parent != null && - curScope.block.parent.type === 'ReturnStatement'; + isInReturnedFunction = curScope.block.parent?.type === 'ReturnStatement'; } - curScope = curScope.upper; + curScope = curScope.upper!; } return isInReturnedFunction; } // Get dependencies from all our resolved references in pure scopes. // Key is dependency string, value is whether it's stable. - const dependencies = new Map(); - const optionalChains = new Map(); + const dependencies = new Map(); + const optionalChains = new Map(); gatherDependenciesRecursively(scope); - function gatherDependenciesRecursively(currentScope) { + function gatherDependenciesRecursively(currentScope: Scope.Scope): void { for (const reference of currentScope.references) { // If this reference is not resolved or it is not declared in a pure // scope then we don't care about this reference. @@ -391,9 +412,9 @@ export default { // Narrow the scope of a dependency if it is, say, a member expression. // Then normalize the narrowed dependency. - const referenceNode = fastFindReferenceWithParent(node, reference.identifier); - const dependencyNode = getDependency(referenceNode); - const dependency = analyzePropertyChain(dependencyNode, optionalChains); + const referenceNode = fastFindReferenceWithParent(node, reference.identifier)!; + const dependencyNode: Node = getDependency(referenceNode); + const dependency: string = analyzePropertyChain(dependencyNode, optionalChains); // Accessing ref.current inside effect cleanup is bad. if ( @@ -401,11 +422,11 @@ export default { isEffect && // ... and this look like accessing .current... dependencyNode.type === 'Identifier' && - (dependencyNode.parent.type === 'MemberExpression' || - dependencyNode.parent.type === 'OptionalMemberExpression') && - !dependencyNode.parent.computed && - dependencyNode.parent.property.type === 'Identifier' && - dependencyNode.parent.property.name === 'current' && + (dependencyNode.parent!.type === 'MemberExpression' || + dependencyNode.parent!.type === 'OptionalMemberExpression') && + !dependencyNode.parent!.computed && + dependencyNode.parent!.property.type === 'Identifier' && + dependencyNode.parent!.property.name === 'current' && // ...in a cleanup function or below... isInsideEffectCleanup(reference) ) { @@ -416,8 +437,8 @@ export default { } if ( - dependencyNode.parent.type === 'TSTypeQuery' || - dependencyNode.parent.type === 'TSTypeReference' + dependencyNode.parent!.type === 'TSTypeQuery' || + dependencyNode.parent!.type === 'TSTypeReference' ) { continue; } @@ -438,7 +459,7 @@ export default { // Add the dependency to a map so we can make sure it is referenced // again in our dependencies array. Remember whether it's stable. if (!dependencies.has(dependency)) { - const resolved = reference.resolved; + const resolved: Scope.Variable = reference.resolved; const isStable = memoizedIsStableKnownHookValue(resolved) || memoizedIsFunctionWithoutCapturedValues(resolved); @@ -447,7 +468,7 @@ export default { references: [reference], }); } else { - dependencies.get(dependency).references.push(reference); + dependencies.get(dependency)!.references.push(reference); } } @@ -458,7 +479,7 @@ export default { // Warn about accessing .current in cleanup effects. currentRefsInEffectCleanup.forEach(({ reference, dependencyNode }, dependency) => { - const references = reference.resolved.references; + const references: Scope.Reference[] = reference.resolved!.references; // Is React managing this ref or us? // Let's see if we can find a .current assignment. let foundCurrentAssignment = false; @@ -474,8 +495,8 @@ export default { parent.property.type === 'Identifier' && parent.property.name === 'current' && // ref.current = - parent.parent.type === 'AssignmentExpression' && - parent.parent.left === parent + parent.parent!.type === 'AssignmentExpression' && + parent.parent!.left === parent ) { foundCurrentAssignment = true; break; @@ -486,7 +507,7 @@ export default { return; } reportProblem({ - node: dependencyNode.parent.property, + node: (dependencyNode.parent as MemberExpression).property, message: `The ref value '${dependency}.current' will likely have ` + `changed by the time this effect cleanup function runs. If ` + @@ -498,8 +519,8 @@ export default { // Warn about assigning to variables in the outer scope. // Those are usually bugs. - const staleAssignments = new Set(); - function reportStaleAssignment(writeExpr, key) { + const staleAssignments = new Set(); + function reportStaleAssignment(writeExpr: Node, key: string): void { if (staleAssignments.has(key)) { return; } @@ -517,7 +538,7 @@ export default { } // Remember which deps are stable and report bad usage first. - const stableDependencies = new Set(); + const stableDependencies = new Set(); dependencies.forEach(({ isStable, references }, key) => { if (isStable) { stableDependencies.add(key); @@ -537,8 +558,8 @@ export default { if (!declaredDependenciesNode) { // Check if there are any top-level setState() calls. // Those tend to lead to infinite loops. - let setStateInsideEffectWithoutDeps = null; - dependencies.forEach(({ isStable, references }, key) => { + let setStateInsideEffectWithoutDeps: string | null = null; + dependencies.forEach(({ references }, key) => { if (setStateInsideEffectWithoutDeps) { return; } @@ -548,14 +569,14 @@ export default { } const id = reference.identifier; - const isSetState = setStateCallSites.has(id); + const isSetState: boolean = setStateCallSites.has(id); if (!isSetState) { return; } - let fnScope = reference.from; + let fnScope: Scope.Scope = reference.from; while (fnScope.type !== 'function') { - fnScope = fnScope.upper; + fnScope = fnScope.upper!; } const isDirectlyInsideEffect = fnScope.block === node; if (isDirectlyInsideEffect) { @@ -564,6 +585,7 @@ export default { } }); }); + if (setStateInsideEffectWithoutDeps) { const { suggestedDependencies } = collectRecommendations({ dependencies, @@ -596,8 +618,8 @@ export default { return; } - const declaredDependencies = []; - const externalDependencies = new Set(); + const declaredDependencies: DeclaredDependency[] = []; + const externalDependencies = new Set(); if (declaredDependenciesNode.type !== 'ArrayExpression') { // If the declared dependencies are not an array expression then we // can't verify that the user provided the correct dependencies. Tell @@ -640,7 +662,7 @@ export default { declaredDependencyNode, )}\``, fix(fixer) { - return fixer.removeRange(declaredDependencyNode.range); + return fixer.removeRange(declaredDependencyNode.range!); }, }, ], @@ -648,13 +670,13 @@ export default { } // Try to normalize the declared dependency. If we can't then an error // will be thrown. We will catch that error and report an error. - let declaredDependency; + let declaredDependency: string; try { declaredDependency = analyzePropertyChain(declaredDependencyNode, null); } catch (error) { if (/Unsupported node type/.test(error.message)) { if (declaredDependencyNode.type === 'Literal') { - if (dependencies.has(declaredDependencyNode.value)) { + if (dependencies.has(declaredDependencyNode.value as any)) { reportProblem({ node: declaredDependencyNode, message: @@ -686,13 +708,15 @@ export default { } } - let maybeID = declaredDependencyNode; + let maybeID: Expression | Super = declaredDependencyNode; while ( maybeID.type === 'MemberExpression' || maybeID.type === 'OptionalMemberExpression' || maybeID.type === 'ChainExpression' ) { - maybeID = maybeID.object || maybeID.expression.object; + maybeID = + (maybeID as MemberExpression | OptionalMemberExpression).object || + ((maybeID as ChainExpression).expression as MemberExpression).object; } const isDeclaredInComponent = !componentScope.through.some( ref => ref.identifier === maybeID, @@ -758,10 +782,12 @@ export default { const message = `The '${construction.name.name}' ${depType} ${causation} the dependencies of ` + - `${reactiveHookName} Hook (at line ${declaredDependenciesNode.loc.start.line}) ` + + `${reactiveHookName} Hook (at line ${ + declaredDependenciesNode.loc!.start.line + }) ` + `change on every render. ${advice}`; - let suggest; + let suggest: Rule.SuggestionReportDescriptor[] | undefined; // Only handle the simple case of variable assignments. // Wrapping function declarations can mess up hoisting. if ( @@ -782,17 +808,18 @@ export default { : ['useCallback(', ')']; return [ // TODO: also add an import? - fixer.insertTextBefore(construction.node.init, before), + fixer.insertTextBefore(construction.node.init!, before), // TODO: ideally we'd gather deps here but it would require // restructuring the rule code. This will cause a new lint // error to appear immediately for useCallback. Note we're // not adding [] because would that changes semantics. - fixer.insertTextAfter(construction.node.init, after), + fixer.insertTextAfter(construction.node.init!, after), ]; }, }, ]; } + // TODO: What if the function needs to change on every render anyway? // Should we suggest removing effect deps as an appropriate fix too? reportProblem({ @@ -811,7 +838,7 @@ export default { // in some extra deduplication. We can't do this // for effects though because those have legit // use cases for over-specifying deps. - if (!isEffect && missingDependencies.size > 0) { + if (!isEffect && missingDependencies.size) { suggestedDeps = collectRecommendations({ dependencies, declaredDependencies: [], // Pretend we don't know @@ -822,7 +849,7 @@ export default { } // Alphabetize the suggestions, but only if deps were already alphabetized. - function areDeclaredDepsAlphabetized() { + function areDeclaredDepsAlphabetized(): boolean { if (declaredDependencies.length === 0) { return true; } @@ -830,6 +857,7 @@ export default { const sortedDeclaredDepKeys = declaredDepKeys.slice().sort(); return declaredDepKeys.join(',') === sortedDeclaredDepKeys.join(','); } + if (areDeclaredDepsAlphabetized()) { suggestedDeps.sort(); } @@ -838,7 +866,7 @@ export default { // This function is the last step before printing a dependency, so now is a good time to // check whether any members in our path are always used as optional-only. In that case, // we will use ?. instead of . to concatenate those parts of the path. - function formatDependency(path) { + function formatDependency(path: string): string { const members = path.split('.'); let finalPath = ''; for (let i = 0; i < members.length; i++) { @@ -852,7 +880,12 @@ export default { return finalPath; } - function getWarningMessage(deps, singlePrefix, label, fixVerb) { + function getWarningMessage( + deps: Set, + singlePrefix: string, + label: string, + fixVerb: string, + ): string | null { if (deps.size === 0) { return null; } @@ -875,7 +908,7 @@ export default { let extraWarning = ''; if (unnecessaryDependencies.size > 0) { - let badRef = null; + let badRef: string | null = null; Array.from(unnecessaryDependencies.keys()).forEach(key => { if (badRef !== null) { return; @@ -908,7 +941,7 @@ export default { if (propDep == null) { return; } - const refs = propDep.references; + const refs: Scope.Reference[] = propDep.references; if (!Array.isArray(refs)) { return; } @@ -942,17 +975,17 @@ export default { } } - if (!extraWarning && missingDependencies.size > 0) { + if (!extraWarning && missingDependencies.size) { // See if the user is trying to avoid specifying a callable prop. // This usually means they're unaware of useCallback. - let missingCallbackDep = null; + let missingCallbackDep: string | null = null; missingDependencies.forEach(missingDep => { if (missingCallbackDep) { return; } // Is this a variable from top scope? const topScopeRef = componentScope.set.get(missingDep); - const usedDep = dependencies.get(missingDep); + const usedDep = dependencies.get(missingDep)!; if (usedDep.references[0].resolved !== topScopeRef) { return; } @@ -963,9 +996,9 @@ export default { } // Was it called in at least one case? Then it's a function. let isFunctionCall = false; - let id; + let id: Identifier; for (let i = 0; i < usedDep.references.length; i++) { - id = usedDep.references[i].identifier; + id = usedDep.references[i].identifier as Identifier; if ( id != null && id.parent != null && @@ -994,37 +1027,41 @@ export default { } if (!extraWarning && missingDependencies.size > 0) { - let setStateRecommendation = null; - missingDependencies.forEach(missingDep => { + let setStateRecommendation: { + missingDep: string; + setter: string; + form: 'updater' | 'inlineReducer' | 'reducer'; + } | null = null; + for (const missingDep of missingDependencies) { if (setStateRecommendation !== null) { - return; + continue; } - const usedDep = dependencies.get(missingDep); + const usedDep = dependencies.get(missingDep)!; const references = usedDep.references; - let id; - let maybeCall; + let id: Identifier; + let maybeCall: Node | null; for (let i = 0; i < references.length; i++) { - id = references[i].identifier; - maybeCall = id.parent; + id = references[i].identifier as Identifier; + maybeCall = id.parent!; // Try to see if we have setState(someExpr(missingDep)). while (maybeCall != null && maybeCall !== componentScope.block) { if (maybeCall.type === 'CallExpression') { const correspondingStateVariable = setStateCallSites.get( - maybeCall.callee, + maybeCall.callee as Expression, ); if (correspondingStateVariable != null) { - if (correspondingStateVariable.name === missingDep) { + if ((correspondingStateVariable as Identifier).name === missingDep) { // setCount(count + 1) setStateRecommendation = { missingDep, - setter: maybeCall.callee.name, + setter: (maybeCall.callee as Identifier).name, form: 'updater', }; } else if (stateVariables.has(id)) { // setCount(count + increment) setStateRecommendation = { missingDep, - setter: maybeCall.callee.name, + setter: (maybeCall.callee as Identifier).name, form: 'reducer', }; } else { @@ -1037,7 +1074,7 @@ export default { if (def != null && def.type === 'Parameter') { setStateRecommendation = { missingDep, - setter: maybeCall.callee.name, + setter: (maybeCall.callee as Identifier).name, form: 'inlineReducer', }; } @@ -1046,13 +1083,14 @@ export default { break; } } - maybeCall = maybeCall.parent; + maybeCall = maybeCall.parent!; } if (setStateRecommendation !== null) { break; } } - }); + } + if (setStateRecommendation !== null) { switch (setStateRecommendation.form) { case 'reducer': @@ -1110,15 +1148,16 @@ export default { }); } - function visitCallExpression(node) { + function visitCallExpression(node: CallExpression): void { const callbackIndex = getReactiveHookCallbackIndex(node.callee, options); if (callbackIndex === -1) { // Not a React Hook call that needs deps. return; } const callback = node.arguments[callbackIndex]; - const reactiveHook = node.callee; - const reactiveHookName = getNodeWithoutReactNamespace(reactiveHook).name; + const reactiveHook = node.callee as Identifier | MemberExpression; + const reactiveHookName = (getNodeWithoutReactNamespace(reactiveHook) as Identifier) + .name; const declaredDependenciesNode = node.arguments[callbackIndex + 1]; const isEffect = /Effect($|[^a-z])/g.test(reactiveHookName); @@ -1172,8 +1211,8 @@ export default { // The function passed as a callback is not written inline. // But perhaps it's in the dependencies array? if ( - declaredDependenciesNode.elements && - declaredDependenciesNode.elements.some( + declaredDependenciesNode.type === 'ArrayExpression' && + declaredDependenciesNode.elements?.some( el => el && el.type === 'Identifier' && el.name === callback.name, ) ) { @@ -1266,6 +1305,27 @@ export default { }, }; +interface Dependencies { + isStable: boolean; + references: Scope.Reference[]; +} + +interface DeclaredDependency { + key: string; + node: Expression; +} + +interface DepTree { + /** True if used in code */ + isUsed: boolean; + /** True if specified in deps */ + isSatisfiedRecursively: boolean; + /** True if something deeper is used by code */ + isSubtreeUsed: boolean; + /** Nodes for properties */ + children: Map; +} + // The meat of the logic. function collectRecommendations({ dependencies, @@ -1273,7 +1333,18 @@ function collectRecommendations({ stableDependencies, externalDependencies, isEffect, -}) { +}: { + dependencies: Map; + declaredDependencies: DeclaredDependency[]; + stableDependencies: Set; + externalDependencies: Set; + isEffect: boolean; +}): { + suggestedDependencies: string[]; + unnecessaryDependencies: Set; + duplicateDependencies: Set; + missingDependencies: Set; +} { // Our primary data structure. // It is a logical representation of property chains: // `props` -> `props.foo` -> `props.foo.bar` -> `props.foo.bar.baz` @@ -1284,12 +1355,13 @@ function collectRecommendations({ // and the nodes that were *declared* as deps. Then we will // traverse it to learn which deps are missing or unnecessary. const depTree = createDepTree(); - function createDepTree() { + + function createDepTree(): DepTree { return { - isUsed: false, // True if used in code - isSatisfiedRecursively: false, // True if specified in deps - isSubtreeUsed: false, // True if something deeper is used by code - children: new Map(), // Nodes for properties + isUsed: false, + isSatisfiedRecursively: false, + isSubtreeUsed: false, + children: new Map(), }; } @@ -1315,7 +1387,7 @@ function collectRecommendations({ }); // Tree manipulation helpers. - function getOrCreateNodeByPath(rootNode, path) { + function getOrCreateNodeByPath(rootNode: DepTree, path: string): DepTree { const keys = path.split('.'); let node = rootNode; for (const key of keys) { @@ -1328,7 +1400,12 @@ function collectRecommendations({ } return node; } - function markAllParentsByPath(rootNode, path, fn) { + + function markAllParentsByPath( + rootNode: DepTree, + path: string, + fn: (depTree: DepTree) => void, + ): void { const keys = path.split('.'); let node = rootNode; for (const key of keys) { @@ -1342,10 +1419,15 @@ function collectRecommendations({ } // Now we can learn which dependencies are missing or necessary. - const missingDependencies = new Set(); - const satisfyingDependencies = new Set(); + const missingDependencies = new Set(); + const satisfyingDependencies = new Set(); scanTreeRecursively(depTree, missingDependencies, satisfyingDependencies, key => key); - function scanTreeRecursively(node, missingPaths, satisfyingPaths, keyToPath) { + function scanTreeRecursively( + node: DepTree, + missingPaths: Set, + satisfyingPaths: Set, + keyToPath: (key: string) => string, + ): void { node.children.forEach((child, key) => { const path = keyToPath(key); if (child.isSatisfiedRecursively) { @@ -1375,13 +1457,13 @@ function collectRecommendations({ } // Collect suggestions in the order they were originally specified. - const suggestedDependencies = []; - const unnecessaryDependencies = new Set(); - const duplicateDependencies = new Set(); + const suggestedDependencies: string[] = []; + const unnecessaryDependencies = new Set(); + const duplicateDependencies = new Set(); declaredDependencies.forEach(({ key }) => { // Does this declared dep satisfy a real need? if (satisfyingDependencies.has(key)) { - if (suggestedDependencies.indexOf(key) === -1) { + if (!suggestedDependencies.includes(key)) { // Good one. suggestedDependencies.push(key); } else { @@ -1419,7 +1501,7 @@ function collectRecommendations({ // If the node will result in constructing a referentially unique value, return // its human readable type name, else return null. -function getConstructionExpressionType(node) { +function getConstructionExpressionType(node: Node) { switch (node.type) { case 'ObjectExpression': return 'object'; @@ -1477,6 +1559,11 @@ function scanForConstructions({ declaredDependenciesNode, componentScope, scope, +}: { + declaredDependencies: DeclaredDependency[]; + declaredDependenciesNode: Node; + componentScope: Scope.Scope; + scope: Scope.Scope; }) { const constructions = declaredDependencies .map(({ key }) => { @@ -1502,23 +1589,23 @@ function scanForConstructions({ ) { const constantExpressionType = getConstructionExpressionType(node.node.init); if (constantExpressionType != null) { - return [ref, constantExpressionType]; + return [ref, constantExpressionType] as const; } } // function handleChange() {} if (node.type === 'FunctionName' && node.node.type === 'FunctionDeclaration') { - return [ref, 'function']; + return [ref, 'function'] as const; } // class Foo {} if (node.type === 'ClassName' && node.node.type === 'ClassDeclaration') { - return [ref, 'class']; + return [ref, 'class'] as const; } return null; }) .filter(Boolean); - function isUsedOutsideOfHook(ref) { + function isUsedOutsideOfHook(ref: Scope.Variable): boolean { let foundWriteExpr = false; for (let i = 0; i < ref.references.length; i++) { const reference = ref.references[i]; @@ -1534,7 +1621,7 @@ function scanForConstructions({ } let currentScope = reference.from; while (currentScope !== scope && currentScope != null) { - currentScope = currentScope.upper; + currentScope = currentScope.upper!; } if (currentScope !== scope) { // This reference is outside the Hook callback. @@ -1561,21 +1648,22 @@ function scanForConstructions({ * props.foo.(bar) => (props).foo.bar * props.foo.bar.(baz) => (props).foo.bar.baz */ -function getDependency(node) { +function getDependency(node: Node): Node { + const parent = node.parent!; if ( - (node.parent.type === 'MemberExpression' || - node.parent.type === 'OptionalMemberExpression') && - node.parent.object === node && - node.parent.property.name !== 'current' && - !node.parent.computed && + (parent.type === 'MemberExpression' || parent.type === 'OptionalMemberExpression') && + parent.object === node && + parent.property.type === 'Identifier' && + parent.property.name !== 'current' && + !parent.computed && !( - node.parent.parent != null && - (node.parent.parent.type === 'CallExpression' || - node.parent.parent.type === 'OptionalCallExpression') && - node.parent.parent.callee === node.parent + parent.parent != null && + (parent.parent.type === 'CallExpression' || + parent.parent.type === 'OptionalCallExpression') && + parent.parent.callee === parent ) ) { - return getDependency(node.parent); + return getDependency(parent); } else if ( // Note: we don't check OptionalMemberExpression because it can't be LHS. node.type === 'MemberExpression' && @@ -1595,9 +1683,13 @@ function getDependency(node) { * It just means there is an optional member somewhere inside. * This particular node might still represent a required member, so check .optional field. */ -function markNode(node, optionalChains, result) { +function markNode( + node: Node, + optionalChains: Map | null, + result: string, +): void { if (optionalChains) { - if (node.optional) { + if ((node as OptionalMemberExpression).optional) { // We only want to consider it optional if *all* usages were optional. if (!optionalChains.has(result)) { // Mark as (maybe) optional. If there's a required usage, this will be overridden. @@ -1617,7 +1709,10 @@ function markNode(node, optionalChains, result) { * foo.bar(.)baz -> 'foo.bar.baz' * Otherwise throw. */ -function analyzePropertyChain(node, optionalChains) { +function analyzePropertyChain( + node: Node, + optionalChains: Map | null, +): string { if (node.type === 'Identifier' || node.type === 'JSXIdentifier') { const result = node.name; if (optionalChains) { @@ -1654,7 +1749,7 @@ function analyzePropertyChain(node, optionalChains) { } } -function getNodeWithoutReactNamespace(node, options) { +function getNodeWithoutReactNamespace(node: Identifier | MemberExpression) { if ( node.type === 'MemberExpression' && node.object.type === 'Identifier' && @@ -1672,7 +1767,13 @@ function getNodeWithoutReactNamespace(node, options) { // 0 for useEffect/useMemo/useCallback(fn). // 1 for useImperativeHandle(ref, fn). // For additionally configured Hooks, assume that they're like useEffect (0). -function getReactiveHookCallbackIndex(calleeNode, options) { +function getReactiveHookCallbackIndex( + calleeNode: Expression | Super, + options: { + additionalHooks?: RegExp; + enableDangerousAutofixThisMayCauseInfiniteLoops: boolean; + }, +): 0 | 1 | -1 { const node = getNodeWithoutReactNamespace(calleeNode); if (node.type !== 'Identifier') { return -1; @@ -1718,12 +1819,12 @@ function getReactiveHookCallbackIndex(calleeNode, options) { * - optimized by only searching nodes with a range surrounding our target node * - agnostic to AST node types, it looks for `{ type: string, ... }` */ -function fastFindReferenceWithParent(start, target) { +function fastFindReferenceWithParent(start: Node, target: Node): Node | null { const queue = [start]; - let item = null; + let item: Node; while (queue.length) { - item = queue.shift(); + item = queue.shift()!; if (isSameIdentifier(item, target)) { return item; @@ -1754,7 +1855,7 @@ function fastFindReferenceWithParent(start, target) { return null; } -function joinEnglish(arr) { +function joinEnglish(arr: string[]): string { let s = ''; for (let i = 0; i < arr.length; i++) { s += arr[i]; @@ -1769,7 +1870,7 @@ function joinEnglish(arr) { return s; } -function isNodeLike(val) { +function isNodeLike(val: any): boolean { return ( typeof val === 'object' && val !== null && @@ -1778,23 +1879,25 @@ function isNodeLike(val) { ); } -function isSameIdentifier(a, b) { +function isSameIdentifier(a: Node, b: Node): boolean { return ( (a.type === 'Identifier' || a.type === 'JSXIdentifier') && a.type === b.type && a.name === b.name && - a.range[0] === b.range[0] && - a.range[1] === b.range[1] + a.range![0] === b.range![0] && + a.range![1] === b.range![1] ); } -function isAncestorNodeOf(a, b) { - return a.range[0] <= b.range[0] && a.range[1] >= b.range[1]; +function isAncestorNodeOf(a: Node, b: Node): boolean { + return a.range![0] <= b.range![0] && a.range![1] >= b.range![1]; } -function isUseEffectEventIdentifier(node) { +function isUseEffectEventIdentifier(node: Node): boolean { if (__EXPERIMENTAL__) { return node.type === 'Identifier' && node.name === 'useEffectEvent'; } return false; } + +export default rule; diff --git a/eslint-plugin-react-hooks/RulesOfHooks.ts b/eslint-plugin-react-hooks/RulesOfHooks.ts index 03b88b6..4366eb2 100644 --- a/eslint-plugin-react-hooks/RulesOfHooks.ts +++ b/eslint-plugin-react-hooks/RulesOfHooks.ts @@ -7,15 +7,23 @@ /* global BigInt */ /* eslint-disable no-for-of-loops/no-for-of-loops */ - -'use strict'; +import type { Rule, Scope } from 'eslint'; +import type { + CallExpression, + Expression, + Super, + Node, + Identifier, + BaseFunction, +} from 'estree'; +import { __EXPERIMENTAL__ } from './index'; /** * Catch all identifiers that begin with "use" followed by an uppercase Latin * character to exclude identifiers like "user". */ -function isHookName(s) { +function isHookName(s: string) { if (__EXPERIMENTAL__) { return s === 'use' || /^use[A-Z0-9]/.test(s); } @@ -26,8 +34,7 @@ function isHookName(s) { * We consider hooks to be a hook name identifier or a member expression * containing a hook name. */ - -function isHook(node) { +function isHook(node: Node) { if (node.type === 'Identifier') { return isHookName(node.name); } else if ( @@ -48,16 +55,16 @@ function isHook(node) { * always start with an uppercase letter. */ -function isComponentName(node) { +function isComponentName(node: Node) { return node.type === 'Identifier' && /^[A-Z]/.test(node.name); } -function isReactFunction(node, functionName) { +function isReactFunction(node: Expression | Super, functionName: string) { return ( - node.name === functionName || + (node as Identifier).name === functionName || (node.type === 'MemberExpression' && - node.object.name === 'React' && - node.property.name === functionName) + (node.object as Identifier).name === 'React' && + (node.property as Identifier).name === functionName) ); } @@ -65,12 +72,10 @@ function isReactFunction(node, functionName) { * Checks if the node is a callback argument of forwardRef. This render function * should follow the rules of hooks. */ - -function isForwardRefCallback(node) { +function isForwardRefCallback(node: Rule.Node) { return !!( - node.parent && - node.parent.callee && - isReactFunction(node.parent.callee, 'forwardRef') + (node.parent as CallExpression)?.callee && + isReactFunction((node.parent as CallExpression).callee, 'forwardRef') ); } @@ -79,15 +84,14 @@ function isForwardRefCallback(node) { * functional component should follow the rules of hooks. */ -function isMemoCallback(node) { +function isMemoCallback(node: Rule.Node) { return !!( - node.parent && - node.parent.callee && - isReactFunction(node.parent.callee, 'memo') + (node.parent as CallExpression)?.callee && + isReactFunction((node.parent as CallExpression).callee, 'memo') ); } -function isInsideComponentOrHook(node) { +function isInsideComponentOrHook(node: Rule.Node) { while (node) { const functionName = getFunctionName(node); if (functionName) { @@ -103,21 +107,21 @@ function isInsideComponentOrHook(node) { return false; } -function isUseEffectEventIdentifier(node) { +function isUseEffectEventIdentifier(node: Node) { if (__EXPERIMENTAL__) { return node.type === 'Identifier' && node.name === 'useEffectEvent'; } return false; } -function isUseIdentifier(node) { +function isUseIdentifier(node: Node) { if (__EXPERIMENTAL__) { return node.type === 'Identifier' && node.name === 'use'; } return false; } -export default { +const rule: Rule.RuleModule = { meta: { type: 'problem', docs: { @@ -127,17 +131,20 @@ export default { }, }, create(context) { - let lastEffect = null; - const codePathReactHooksMapStack = []; - const codePathSegmentStack = []; - const useEffectEventFunctions = new WeakSet(); + let lastEffect: CallExpression | null = null; + const codePathReactHooksMapStack: Map< + Rule.CodePathSegment, + (Expression | Super)[] + >[] = []; + const codePathSegmentStack: Rule.CodePathSegment[] = []; + const useEffectEventFunctions = new WeakSet(); // For a given scope, iterate through the references and add all useEffectEvent definitions. We can // do this in non-Program nodes because we can rely on the assumption that useEffectEvent functions // can only be declared within a component or hook at its top level. - function recordAllUseEffectEventFunctions(scope) { + function recordAllUseEffectEventFunctions(scope: Scope.Scope) { for (const reference of scope.references) { - const parent = reference.identifier.parent; + const parent = reference.identifier.parent!; if ( parent.type === 'VariableDeclarator' && parent.init && @@ -145,7 +152,7 @@ export default { parent.init.callee && isUseEffectEventIdentifier(parent.init.callee) ) { - for (const ref of reference.resolved.references) { + for (const ref of reference.resolved!.references) { if (ref !== reference) { useEffectEventFunctions.add(ref.identifier); } @@ -167,7 +174,7 @@ export default { // Everything is ok if all React Hooks are both reachable from the initial // segment and reachable from every final segment. onCodePathEnd(codePath, codePathNode) { - const reactHooksMap = codePathReactHooksMapStack.pop(); + const reactHooksMap = codePathReactHooksMapStack.pop()!; if (reactHooksMap.size === 0) { return; } @@ -197,7 +204,10 @@ export default { * Populates `cyclic` with cyclic segments. */ - function countPathsFromStart(segment, pathHistory) { + function countPathsFromStart( + segment: Rule.CodePathSegment, + pathHistory?: Set, + ) { const { cache } = countPathsFromStart; let paths = cache.get(segment.id); const pathList = new Set(pathHistory); @@ -211,7 +221,7 @@ export default { cyclic.add(cyclicSegment); } - return BigInt('0'); + return 0n; } // add the current segment to pathList @@ -223,11 +233,11 @@ export default { } if (codePath.thrownSegments.includes(segment)) { - paths = BigInt('0'); + paths = 0n; } else if (segment.prevSegments.length === 0) { - paths = BigInt('1'); + paths = 1n; } else { - paths = BigInt('0'); + paths = 0n; for (const prevSegment of segment.prevSegments) { paths += countPathsFromStart(prevSegment, pathList); } @@ -266,7 +276,10 @@ export default { * Populates `cyclic` with cyclic segments. */ - function countPathsToEnd(segment, pathHistory) { + function countPathsToEnd( + segment: Rule.CodePathSegment, + pathHistory?: Set, + ): bigint { const { cache } = countPathsToEnd; let paths = cache.get(segment.id); const pathList = new Set(pathHistory); @@ -280,7 +293,7 @@ export default { cyclic.add(cyclicSegment); } - return BigInt('0'); + return 0n; } // add the current segment to pathList @@ -292,11 +305,11 @@ export default { } if (codePath.thrownSegments.includes(segment)) { - paths = BigInt('0'); + paths = 0n; } else if (segment.nextSegments.length === 0) { - paths = BigInt('1'); + paths = 1n; } else { - paths = BigInt('0'); + paths = 0n; for (const nextSegment of segment.nextSegments) { paths += countPathsToEnd(nextSegment, pathList); } @@ -328,7 +341,7 @@ export default { * so we would return that. */ - function shortestPathLengthToStart(segment) { + function shortestPathLengthToStart(segment: Rule.CodePathSegment): number { const { cache } = shortestPathLengthToStart; let length = cache.get(segment.id); @@ -361,9 +374,9 @@ export default { return length; } - countPathsFromStart.cache = new Map(); - countPathsToEnd.cache = new Map(); - shortestPathLengthToStart.cache = new Map(); + countPathsFromStart.cache = new Map(); + countPathsToEnd.cache = new Map(); + shortestPathLengthToStart.cache = new Map(); // Count all code paths to the end of our component/hook. Also primes // the `countPathsToEnd` cache. @@ -480,7 +493,7 @@ export default { // called in. if (isDirectlyInsideComponentOrHook) { // Report an error if the hook is called inside an async function. - const isAsyncFunction = codePathNode.async; + const isAsyncFunction = (codePathNode as BaseFunction).async; if (isAsyncFunction) { context.report({ node: hook, @@ -565,8 +578,8 @@ export default { if (isHook(node.callee)) { // Add the hook node to a map keyed by the code path segment. We will // do full code path analysis at the end of our code path. - const reactHooksMap = last(codePathReactHooksMapStack); - const codePathSegment = last(codePathSegmentStack); + const reactHooksMap = codePathReactHooksMapStack.at(-1)!; + const codePathSegment = codePathSegmentStack.at(-1)!; let reactHooks = reactHooksMap.get(codePathSegment); if (!reactHooks) { reactHooks = []; @@ -637,8 +650,8 @@ export default { * where JS gives anonymous function expressions names. We roughly detect the * same AST nodes with some exceptions to better fit our use case. */ - -function getFunctionName(node) { +function getFunctionName(node: Node) { + const parent = node.parent!; if ( node.type === 'FunctionDeclaration' || (node.type === 'FunctionExpression' && node.id) @@ -653,24 +666,20 @@ function getFunctionName(node) { node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression' ) { - if (node.parent.type === 'VariableDeclarator' && node.parent.init === node) { + if (parent.type === 'VariableDeclarator' && parent.init === node) { // const useHook = () => {}; - return node.parent.id; + return parent.id; } else if ( - node.parent.type === 'AssignmentExpression' && - node.parent.right === node && - node.parent.operator === '=' + parent.type === 'AssignmentExpression' && + parent.right === node && + parent.operator === '=' ) { // useHook = () => {}; - return node.parent.left; - } else if ( - node.parent.type === 'Property' && - node.parent.value === node && - !node.parent.computed - ) { + return parent.left; + } else if (parent.type === 'Property' && parent.value === node && !parent.computed) { // {useHook: () => {}} // {useHook() {}} - return node.parent.key; + return parent.key; // NOTE: We could also support `ClassProperty` and `MethodDefinition` // here to be pedantic. However, hooks in a class are an anti-pattern. So @@ -679,16 +688,16 @@ function getFunctionName(node) { // class {useHook = () => {}} // class {useHook() {}} } else if ( - node.parent.type === 'AssignmentPattern' && - node.parent.right === node && - !node.parent.computed + parent.type === 'AssignmentPattern' && + parent.right === node && + !parent.computed ) { // const {useHook = () => {}} = {}; // ({useHook = () => {}} = {}); // // Kinda clowny, but we'd said we'd follow spec convention for // `IsAnonymousFunctionDefinition()` usage. - return node.parent.left; + return parent.left; } else { return undefined; } @@ -697,10 +706,4 @@ function getFunctionName(node) { } } -/** - * Convenience function for peeking the last item in a stack. - */ - -function last(array) { - return array[array.length - 1]; -} +export default rule; diff --git a/eslint-plugin-react-hooks/index.ts b/eslint-plugin-react-hooks/index.ts index 57eea02..7b7df4a 100644 --- a/eslint-plugin-react-hooks/index.ts +++ b/eslint-plugin-react-hooks/index.ts @@ -4,10 +4,12 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ - +import type { Linter } from 'eslint'; import RulesOfHooks from './RulesOfHooks'; import ExhaustiveDeps from './ExhaustiveDeps'; +export const __EXPERIMENTAL__ = false; + export const configs = { recommended: { plugins: ['react-hooks'], @@ -15,7 +17,7 @@ export const configs = { 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'warn', }, - }, + } as Linter.BaseConfig, }; export const rules = { diff --git a/eslint-plugin-react-hooks/package.json b/eslint-plugin-react-hooks/package.json new file mode 100644 index 0000000..0e80d74 --- /dev/null +++ b/eslint-plugin-react-hooks/package.json @@ -0,0 +1,12 @@ +{ + "upstream": { + "version": 1, + "sources": { + "main": { + "repository": "git@github.com:facebook/react.git", + "commit": "899cb95f52cc83ab5ca1eb1e268c909d3f0961e7", + "branch": "main" + } + } + } +} diff --git a/eslint-plugin-react-hooks/types.d.ts b/eslint-plugin-react-hooks/types.d.ts new file mode 100644 index 0000000..9c75a41 --- /dev/null +++ b/eslint-plugin-react-hooks/types.d.ts @@ -0,0 +1,65 @@ +import type { BaseNode } from 'estree'; + +declare module 'eslint' { + namespace Rule { + interface RuleContext { + getSource(node: BaseNode): string; + } + } +} + +declare module 'estree' { + interface BaseNodeWithoutComments { + parent?: Node; + } + interface NodeMap { + OptionalCallExpression: OptionalCallExpression; + OptionalMemberExpression: OptionalMemberExpression; + TSAsExpression: TSAsExpression; + TSTypeQuery: TSTypeQuery; + TSTypeReference: TSTypeReference; + TypeCastExpression: TypeCastExpression; + } + interface ExpressionMap { + OptionalCallExpression: OptionalCallExpression; + OptionalMemberExpression: OptionalMemberExpression; + TSAsExpression: TSAsExpression; + TypeCastExpression: TypeCastExpression; + } + interface AssignmentPattern { + computed?: boolean; + } + interface ChainExpression { + computed?: boolean; + } + interface TypeCastExpression extends BaseNode { + type: 'TypeCastExpression'; + expression: Expression; + } + interface TSAsExpression extends BaseNode { + type: 'TSAsExpression'; + expression: Expression; + } + interface TSTypeQuery extends BaseNode { + type: 'TSTypeQuery'; + } + interface TSTypeReference extends BaseNode { + type: 'TSTypeReference'; + } + /** @deprecated flow only */ + interface TypeParameter extends BaseNode { + type: 'TypeParameter'; + } + interface OptionalMemberExpression extends BaseNode { + type: 'OptionalMemberExpression'; + object: Expression | Super; + property: Expression | PrivateIdentifier; + computed: boolean; + optional: boolean; + } + interface OptionalCallExpression extends BaseNode { + type: 'OptionalCallExpression'; + callee: Expression | Super; + arguments: (Expression | SpreadElement)[]; + } +} diff --git a/package.json b/package.json index 9398c06..46e84c9 100644 --- a/package.json +++ b/package.json @@ -1,4 +1,5 @@ { + "name": "@aet/eslint-configs", "scripts": { "build": "./esbuild.ts", "check-import": "for js in dist/*.js; do cat $js | grep 'require('; done" @@ -10,17 +11,28 @@ "@babel/preset-env": "^7.22.9", "@types/babel-plugin-macros": "^3.1.0", "@types/babel__core": "^7.20.1", + "@types/eslint": "^8.44.0", + "@types/estree": "^1.0.1", + "@types/estree-jsx": "^1.0.0", + "@types/lodash": "^4.14.195", "@types/node": "^20.4.2", + "@typescript-eslint/types": "^6.1.0", "babel-plugin-macros": "^3.1.0", + "dts-bundle-generator": "^8.0.1", "esbin": "0.0.1-beta.1", "esbuild": "0.18.14", "esbuild-plugin-alias": "^0.2.1", "esbuild-register": "3.4.2", "eslint": "8.45.0", "eslint-config-prettier": "8.8.0", + "eslint-define-config": "^1.21.0", "eslint-plugin-import": "^2.27.5", "glob": "^10.3.3", - "prettier": "^3.0.0" + "json-schema-to-ts": "^2.9.1", + "lodash": "^4.17.21", + "picocolors": "^1.0.0", + "prettier": "^3.0.0", + "typescript": "5.1.6" }, "prettier": { "arrowParens": "avoid", diff --git a/patch/eslint-plugin-import.patch b/patch/eslint-plugin-import.patch index 551b392..cf65831 100644 --- a/patch/eslint-plugin-import.patch +++ b/patch/eslint-plugin-import.patch @@ -277,6 +277,116 @@ index 709a4744..00000000 - }, - ], -} +diff --git a/config/electron.js b/config/electron.js +index f98ff061..0f3aa51d 100644 +--- a/config/electron.js ++++ b/config/electron.js +@@ -1,7 +1,7 @@ + /** + * Default settings for Electron applications. + */ +-module.exports = { ++export default { + settings: { + 'import/core-modules': ['electron'], + }, +diff --git a/config/errors.js b/config/errors.js +index 127c29a0..b46a4c0b 100644 +--- a/config/errors.js ++++ b/config/errors.js +@@ -1,9 +1,8 @@ + /** + * unopinionated config. just the things that are necessarily runtime errors + * waiting to happen. +- * @type {Object} + */ +-module.exports = { ++export default { + plugins: ['import'], + rules: { 'import/no-unresolved': 2, + 'import/named': 2, +diff --git a/config/react-native.js b/config/react-native.js +index a1aa0ee5..97bdf0cf 100644 +--- a/config/react-native.js ++++ b/config/react-native.js +@@ -1,7 +1,7 @@ + /** + * - adds platform extensions to Node resolver + */ +-module.exports = { ++export default { + settings: { + 'import/resolver': { + node: { +diff --git a/config/react.js b/config/react.js +index 68555512..8e090a83 100644 +--- a/config/react.js ++++ b/config/react.js +@@ -5,7 +5,7 @@ + * define jsnext:main and have JSX internally, you may run into problems + * if you don't enable these settings at the top level. + */ +-module.exports = { ++export default { + + settings: { + 'import/extensions': ['.js', '.jsx'], +diff --git a/config/recommended.js b/config/recommended.js +index 8e7ca9fd..9ced8146 100644 +--- a/config/recommended.js ++++ b/config/recommended.js +@@ -1,8 +1,7 @@ + /** + * The basics. +- * @type {Object} + */ +-module.exports = { ++export default { + plugins: ['import'], + + rules: { +diff --git a/config/stage-0.js b/config/stage-0.js +index 42419123..01ebeeb8 100644 +--- a/config/stage-0.js ++++ b/config/stage-0.js +@@ -2,9 +2,8 @@ + * Rules in progress. + * + * Do not expect these to adhere to semver across releases. +- * @type {Object} + */ +-module.exports = { ++export default { + plugins: ['import'], + rules: { + 'import/no-deprecated': 1, +diff --git a/config/typescript.js b/config/typescript.js +index 9fd789db..c277b6c5 100644 +--- a/config/typescript.js ++++ b/config/typescript.js +@@ -7,7 +7,7 @@ + // `.ts`/`.tsx`/`.js`/`.jsx` implementation. + const allExtensions = ['.ts', '.tsx', '.js', '.jsx']; + +-module.exports = { ++export default { + + settings: { + 'import/extensions': allExtensions, +diff --git a/config/warnings.js b/config/warnings.js +index 5d74143b..ffa27d8d 100644 +--- a/config/warnings.js ++++ b/config/warnings.js +@@ -1,8 +1,7 @@ + /** + * more opinionated config. +- * @type {Object} + */ +-module.exports = { ++export default { + plugins: ['import'], + rules: { + 'import/no-named-as-default': 1, diff --git a/scripts/resolverDirectories.js b/scripts/resolverDirectories.js index f0c03a3c..a7cadb55 100644 --- a/scripts/resolverDirectories.js @@ -302,10 +412,10 @@ index 92b838c0..ccb13ba0 100644 return `${repoUrl}/blob/${commitish}/docs/rules/${ruleName}.md`; } diff --git a/src/index.js b/src/index.js -index feafba90..84992bef 100644 +index feafba90..9a464041 100644 --- a/src/index.js +++ b/src/index.js -@@ -1,71 +1,132 @@ +@@ -1,71 +1,135 @@ -export const rules = { - 'no-unresolved': require('./rules/no-unresolved'), - named: require('./rules/named'), @@ -383,6 +493,9 @@ index feafba90..84992bef 100644 - 'no-named-as-default-member': require('./rules/no-named-as-default-member'), - 'no-anonymous-default-export': require('./rules/no-anonymous-default-export'), - 'no-unused-modules': require('./rules/no-unused-modules'), ++/** ++ * @type {Readonly} ++ */ +export const rules = /*#__PURE__*/ kebabCase({ + noUnresolved, + named, @@ -464,9 +577,8 @@ index feafba90..84992bef 100644 + importsFirst, +}); --export const configs = { + export const configs = { - recommended: require('../config/recommended'), -+export const configs = /*#__PURE__*/ kebabCase({ + recommended, - errors: require('../config/errors'), @@ -483,12 +595,11 @@ index feafba90..84992bef 100644 - 'react-native': require('../config/react-native'), - electron: require('../config/electron'), - typescript: require('../config/typescript'), --}; + react, -+ reactNative, ++ 'react-native': reactNative, + electron, + typescript, -+}); + }; + +function kebabCase(obj) { + return Object.fromEntries( diff --git a/patch/eslint-plugin-jsx-a11y.patch b/patch/eslint-plugin-jsx-a11y.patch index 6dd95ef..d317e69 100644 --- a/patch/eslint-plugin-jsx-a11y.patch +++ b/patch/eslint-plugin-jsx-a11y.patch @@ -1,8 +1,8 @@ diff --git a/src/index.js b/src/index.js -index 7b931fe..f7c1f91 100644 +index 7b931fe..eaea267 100644 --- a/src/index.js +++ b/src/index.js -@@ -1,47 +1,87 @@ +@@ -1,296 +1,344 @@ /* eslint-disable global-require */ +// @ts-check +import accessibleEmoji from './rules/accessible-emoji'; @@ -45,7 +45,7 @@ index 7b931fe..f7c1f91 100644 +import scope from './rules/scope'; +import tabindexNoPositive from './rules/tabindex-no-positive'; - module.exports = { +-module.exports = { - rules: { - 'accessible-emoji': require('./rules/accessible-emoji'), - 'alt-text': require('./rules/alt-text'), @@ -87,58 +87,532 @@ index 7b931fe..f7c1f91 100644 - scope: require('./rules/scope'), - 'tabindex-no-positive': require('./rules/tabindex-no-positive'), - }, -+ rules: kebabCase({ -+ accessibleEmoji, -+ altText, -+ anchorAmbiguousText, -+ anchorHasContent, -+ anchorIsValid, -+ ariaActivedescendantHasTabindex, -+ ariaProps, -+ ariaProptypes, -+ ariaRole, -+ ariaUnsupportedElements, -+ autocompleteValid, -+ clickEventsHaveKeyEvents, -+ controlHasAssociatedLabel, -+ headingHasContent, -+ htmlHasLang, -+ iframeHasTitle, -+ imgRedundantAlt, -+ interactiveSupportsFocus, -+ labelHasAssociatedControl, -+ labelHasFor, -+ lang, -+ mediaHasCaption, -+ mouseEventsHaveKeyEvents, -+ noAccessKey, -+ noAriaHiddenOnFocusable, -+ noAutofocus, -+ noDistractingElements, -+ noInteractiveElementToNoninteractiveRole, -+ noNoninteractiveElementInteractions, -+ noNoninteractiveElementToInteractiveRole, -+ noNoninteractiveTabindex, -+ noOnChange, -+ noRedundantRoles, -+ noStaticElementInteractions, -+ preferTagOverRole, -+ roleHasRequiredAriaProps, -+ roleSupportsAriaProps, -+ scope, -+ tabindexNoPositive, -+ }), - configs: { - recommended: { - plugins: [ -@@ -294,3 +334,9 @@ module.exports = { +- configs: { +- recommended: { +- plugins: [ +- 'jsx-a11y', ++export const rules = kebabCase({ ++ accessibleEmoji, ++ altText, ++ anchorAmbiguousText, ++ anchorHasContent, ++ anchorIsValid, ++ ariaActivedescendantHasTabindex, ++ ariaProps, ++ ariaProptypes, ++ ariaRole, ++ ariaUnsupportedElements, ++ autocompleteValid, ++ clickEventsHaveKeyEvents, ++ controlHasAssociatedLabel, ++ headingHasContent, ++ htmlHasLang, ++ iframeHasTitle, ++ imgRedundantAlt, ++ interactiveSupportsFocus, ++ labelHasAssociatedControl, ++ labelHasFor, ++ lang, ++ mediaHasCaption, ++ mouseEventsHaveKeyEvents, ++ noAccessKey, ++ noAriaHiddenOnFocusable, ++ noAutofocus, ++ noDistractingElements, ++ noInteractiveElementToNoninteractiveRole, ++ noNoninteractiveElementInteractions, ++ noNoninteractiveElementToInteractiveRole, ++ noNoninteractiveTabindex, ++ noOnChange, ++ noRedundantRoles, ++ noStaticElementInteractions, ++ preferTagOverRole, ++ roleHasRequiredAriaProps, ++ roleSupportsAriaProps, ++ scope, ++ tabindexNoPositive, ++}); ++export const configs = { ++ recommended: { ++ plugins: [ ++ 'jsx-a11y', ++ ], ++ parserOptions: { ++ ecmaFeatures: { ++ jsx: true, ++ }, ++ }, ++ rules: { ++ 'jsx-a11y/alt-text': 'error', ++ 'jsx-a11y/anchor-ambiguous-text': 'off', // TODO: error ++ 'jsx-a11y/anchor-has-content': 'error', ++ 'jsx-a11y/anchor-is-valid': 'error', ++ 'jsx-a11y/aria-activedescendant-has-tabindex': 'error', ++ 'jsx-a11y/aria-props': 'error', ++ 'jsx-a11y/aria-proptypes': 'error', ++ 'jsx-a11y/aria-role': 'error', ++ 'jsx-a11y/aria-unsupported-elements': 'error', ++ 'jsx-a11y/autocomplete-valid': 'error', ++ 'jsx-a11y/click-events-have-key-events': 'error', ++ 'jsx-a11y/control-has-associated-label': ['off', { ++ ignoreElements: [ ++ 'audio', ++ 'canvas', ++ 'embed', ++ 'input', ++ 'textarea', ++ 'tr', ++ 'video', ++ ], ++ ignoreRoles: [ ++ 'grid', ++ 'listbox', ++ 'menu', ++ 'menubar', ++ 'radiogroup', ++ 'row', ++ 'tablist', ++ 'toolbar', ++ 'tree', ++ 'treegrid', ++ ], ++ includeRoles: [ ++ 'alert', ++ 'dialog', ++ ], ++ }], ++ 'jsx-a11y/heading-has-content': 'error', ++ 'jsx-a11y/html-has-lang': 'error', ++ 'jsx-a11y/iframe-has-title': 'error', ++ 'jsx-a11y/img-redundant-alt': 'error', ++ 'jsx-a11y/interactive-supports-focus': [ ++ 'error', ++ { ++ tabbable: [ ++ 'button', ++ 'checkbox', ++ 'link', ++ 'searchbox', ++ 'spinbutton', ++ 'switch', ++ 'textbox', ++ ], ++ }, + ], +- parserOptions: { +- ecmaFeatures: { +- jsx: true, ++ 'jsx-a11y/label-has-associated-control': 'error', ++ 'jsx-a11y/label-has-for': 'off', ++ 'jsx-a11y/media-has-caption': 'error', ++ 'jsx-a11y/mouse-events-have-key-events': 'error', ++ 'jsx-a11y/no-access-key': 'error', ++ 'jsx-a11y/no-autofocus': 'error', ++ 'jsx-a11y/no-distracting-elements': 'error', ++ 'jsx-a11y/no-interactive-element-to-noninteractive-role': [ ++ 'error', ++ { ++ tr: ['none', 'presentation'], ++ canvas: ['img'], + }, +- }, +- rules: { +- 'jsx-a11y/alt-text': 'error', +- 'jsx-a11y/anchor-ambiguous-text': 'off', // TODO: error +- 'jsx-a11y/anchor-has-content': 'error', +- 'jsx-a11y/anchor-is-valid': 'error', +- 'jsx-a11y/aria-activedescendant-has-tabindex': 'error', +- 'jsx-a11y/aria-props': 'error', +- 'jsx-a11y/aria-proptypes': 'error', +- 'jsx-a11y/aria-role': 'error', +- 'jsx-a11y/aria-unsupported-elements': 'error', +- 'jsx-a11y/autocomplete-valid': 'error', +- 'jsx-a11y/click-events-have-key-events': 'error', +- 'jsx-a11y/control-has-associated-label': ['off', { +- ignoreElements: [ +- 'audio', +- 'canvas', +- 'embed', +- 'input', +- 'textarea', +- 'tr', +- 'video', ++ ], ++ 'jsx-a11y/no-noninteractive-element-interactions': [ ++ 'error', ++ { ++ handlers: [ ++ 'onClick', ++ 'onError', ++ 'onLoad', ++ 'onMouseDown', ++ 'onMouseUp', ++ 'onKeyPress', ++ 'onKeyDown', ++ 'onKeyUp', + ], +- ignoreRoles: [ +- 'grid', ++ alert: ['onKeyUp', 'onKeyDown', 'onKeyPress'], ++ body: ['onError', 'onLoad'], ++ dialog: ['onKeyUp', 'onKeyDown', 'onKeyPress'], ++ iframe: ['onError', 'onLoad'], ++ img: ['onError', 'onLoad'], ++ }, ++ ], ++ 'jsx-a11y/no-noninteractive-element-to-interactive-role': [ ++ 'error', ++ { ++ ul: [ + 'listbox', + 'menu', + 'menubar', + 'radiogroup', +- 'row', + 'tablist', +- 'toolbar', + 'tree', + 'treegrid', + ], +- includeRoles: [ +- 'alert', +- 'dialog', +- ], +- }], +- 'jsx-a11y/heading-has-content': 'error', +- 'jsx-a11y/html-has-lang': 'error', +- 'jsx-a11y/iframe-has-title': 'error', +- 'jsx-a11y/img-redundant-alt': 'error', +- 'jsx-a11y/interactive-supports-focus': [ +- 'error', +- { +- tabbable: [ +- 'button', +- 'checkbox', +- 'link', +- 'searchbox', +- 'spinbutton', +- 'switch', +- 'textbox', +- ], +- }, +- ], +- 'jsx-a11y/label-has-associated-control': 'error', +- 'jsx-a11y/label-has-for': 'off', +- 'jsx-a11y/media-has-caption': 'error', +- 'jsx-a11y/mouse-events-have-key-events': 'error', +- 'jsx-a11y/no-access-key': 'error', +- 'jsx-a11y/no-autofocus': 'error', +- 'jsx-a11y/no-distracting-elements': 'error', +- 'jsx-a11y/no-interactive-element-to-noninteractive-role': [ +- 'error', +- { +- tr: ['none', 'presentation'], +- canvas: ['img'], +- }, +- ], +- 'jsx-a11y/no-noninteractive-element-interactions': [ +- 'error', +- { +- handlers: [ +- 'onClick', +- 'onError', +- 'onLoad', +- 'onMouseDown', +- 'onMouseUp', +- 'onKeyPress', +- 'onKeyDown', +- 'onKeyUp', +- ], +- alert: ['onKeyUp', 'onKeyDown', 'onKeyPress'], +- body: ['onError', 'onLoad'], +- dialog: ['onKeyUp', 'onKeyDown', 'onKeyPress'], +- iframe: ['onError', 'onLoad'], +- img: ['onError', 'onLoad'], +- }, +- ], +- 'jsx-a11y/no-noninteractive-element-to-interactive-role': [ +- 'error', +- { +- ul: [ +- 'listbox', +- 'menu', +- 'menubar', +- 'radiogroup', +- 'tablist', +- 'tree', +- 'treegrid', +- ], +- ol: [ +- 'listbox', +- 'menu', +- 'menubar', +- 'radiogroup', +- 'tablist', +- 'tree', +- 'treegrid', +- ], +- li: ['menuitem', 'option', 'row', 'tab', 'treeitem'], +- table: ['grid'], +- td: ['gridcell'], +- fieldset: ['radiogroup', 'presentation'], +- }, +- ], +- 'jsx-a11y/no-noninteractive-tabindex': [ +- 'error', +- { +- tags: [], +- roles: ['tabpanel'], +- allowExpressionValues: true, +- }, +- ], +- 'jsx-a11y/no-redundant-roles': 'error', +- 'jsx-a11y/no-static-element-interactions': [ +- 'error', +- { +- allowExpressionValues: true, +- handlers: [ +- 'onClick', +- 'onMouseDown', +- 'onMouseUp', +- 'onKeyPress', +- 'onKeyDown', +- 'onKeyUp', +- ], +- }, +- ], +- 'jsx-a11y/role-has-required-aria-props': 'error', +- 'jsx-a11y/role-supports-aria-props': 'error', +- 'jsx-a11y/scope': 'error', +- 'jsx-a11y/tabindex-no-positive': 'error', +- }, +- }, +- strict: { +- plugins: [ +- 'jsx-a11y', +- ], +- parserOptions: { +- ecmaFeatures: { +- jsx: true, +- }, +- }, +- rules: { +- 'jsx-a11y/alt-text': 'error', +- 'jsx-a11y/anchor-has-content': 'error', +- 'jsx-a11y/anchor-is-valid': 'error', +- 'jsx-a11y/aria-activedescendant-has-tabindex': 'error', +- 'jsx-a11y/aria-props': 'error', +- 'jsx-a11y/aria-proptypes': 'error', +- 'jsx-a11y/aria-role': 'error', +- 'jsx-a11y/aria-unsupported-elements': 'error', +- 'jsx-a11y/autocomplete-valid': 'error', +- 'jsx-a11y/click-events-have-key-events': 'error', +- 'jsx-a11y/control-has-associated-label': ['off', { +- ignoreElements: [ +- 'audio', +- 'canvas', +- 'embed', +- 'input', +- 'textarea', +- 'tr', +- 'video', +- ], +- ignoreRoles: [ +- 'grid', ++ ol: [ + 'listbox', + 'menu', + 'menubar', + 'radiogroup', +- 'row', + 'tablist', +- 'toolbar', + 'tree', + 'treegrid', + ], +- includeRoles: [ +- 'alert', +- 'dialog', ++ li: ['menuitem', 'option', 'row', 'tab', 'treeitem'], ++ table: ['grid'], ++ td: ['gridcell'], ++ fieldset: ['radiogroup', 'presentation'], ++ }, ++ ], ++ 'jsx-a11y/no-noninteractive-tabindex': [ ++ 'error', ++ { ++ tags: [], ++ roles: ['tabpanel'], ++ allowExpressionValues: true, ++ }, ++ ], ++ 'jsx-a11y/no-redundant-roles': 'error', ++ 'jsx-a11y/no-static-element-interactions': [ ++ 'error', ++ { ++ allowExpressionValues: true, ++ handlers: [ ++ 'onClick', ++ 'onMouseDown', ++ 'onMouseUp', ++ 'onKeyPress', ++ 'onKeyDown', ++ 'onKeyUp', + ], +- }], +- 'jsx-a11y/heading-has-content': 'error', +- 'jsx-a11y/html-has-lang': 'error', +- 'jsx-a11y/iframe-has-title': 'error', +- 'jsx-a11y/img-redundant-alt': 'error', +- 'jsx-a11y/interactive-supports-focus': [ +- 'error', +- { +- tabbable: [ +- 'button', +- 'checkbox', +- 'link', +- 'progressbar', +- 'searchbox', +- 'slider', +- 'spinbutton', +- 'switch', +- 'textbox', +- ], +- }, ++ }, ++ ], ++ 'jsx-a11y/role-has-required-aria-props': 'error', ++ 'jsx-a11y/role-supports-aria-props': 'error', ++ 'jsx-a11y/scope': 'error', ++ 'jsx-a11y/tabindex-no-positive': 'error', ++ }, ++ }, ++ strict: { ++ plugins: [ ++ 'jsx-a11y', ++ ], ++ parserOptions: { ++ ecmaFeatures: { ++ jsx: true, ++ }, ++ }, ++ rules: { ++ 'jsx-a11y/alt-text': 'error', ++ 'jsx-a11y/anchor-has-content': 'error', ++ 'jsx-a11y/anchor-is-valid': 'error', ++ 'jsx-a11y/aria-activedescendant-has-tabindex': 'error', ++ 'jsx-a11y/aria-props': 'error', ++ 'jsx-a11y/aria-proptypes': 'error', ++ 'jsx-a11y/aria-role': 'error', ++ 'jsx-a11y/aria-unsupported-elements': 'error', ++ 'jsx-a11y/autocomplete-valid': 'error', ++ 'jsx-a11y/click-events-have-key-events': 'error', ++ 'jsx-a11y/control-has-associated-label': ['off', { ++ ignoreElements: [ ++ 'audio', ++ 'canvas', ++ 'embed', ++ 'input', ++ 'textarea', ++ 'tr', ++ 'video', + ], +- 'jsx-a11y/label-has-for': 'off', +- 'jsx-a11y/label-has-associated-control': 'error', +- 'jsx-a11y/media-has-caption': 'error', +- 'jsx-a11y/mouse-events-have-key-events': 'error', +- 'jsx-a11y/no-access-key': 'error', +- 'jsx-a11y/no-autofocus': 'error', +- 'jsx-a11y/no-distracting-elements': 'error', +- 'jsx-a11y/no-interactive-element-to-noninteractive-role': 'error', +- 'jsx-a11y/no-noninteractive-element-interactions': [ +- 'error', +- { +- body: ['onError', 'onLoad'], +- iframe: ['onError', 'onLoad'], +- img: ['onError', 'onLoad'], +- }, ++ ignoreRoles: [ ++ 'grid', ++ 'listbox', ++ 'menu', ++ 'menubar', ++ 'radiogroup', ++ 'row', ++ 'tablist', ++ 'toolbar', ++ 'tree', ++ 'treegrid', + ], +- 'jsx-a11y/no-noninteractive-element-to-interactive-role': 'error', +- 'jsx-a11y/no-noninteractive-tabindex': 'error', +- 'jsx-a11y/no-redundant-roles': 'error', +- 'jsx-a11y/no-static-element-interactions': 'error', +- 'jsx-a11y/role-has-required-aria-props': 'error', +- 'jsx-a11y/role-supports-aria-props': 'error', +- 'jsx-a11y/scope': 'error', +- 'jsx-a11y/tabindex-no-positive': 'error', +- }, ++ includeRoles: [ ++ 'alert', ++ 'dialog', ++ ], ++ }], ++ 'jsx-a11y/heading-has-content': 'error', ++ 'jsx-a11y/html-has-lang': 'error', ++ 'jsx-a11y/iframe-has-title': 'error', ++ 'jsx-a11y/img-redundant-alt': 'error', ++ 'jsx-a11y/interactive-supports-focus': [ ++ 'error', ++ { ++ tabbable: [ ++ 'button', ++ 'checkbox', ++ 'link', ++ 'progressbar', ++ 'searchbox', ++ 'slider', ++ 'spinbutton', ++ 'switch', ++ 'textbox', ++ ], ++ }, ++ ], ++ 'jsx-a11y/label-has-for': 'off', ++ 'jsx-a11y/label-has-associated-control': 'error', ++ 'jsx-a11y/media-has-caption': 'error', ++ 'jsx-a11y/mouse-events-have-key-events': 'error', ++ 'jsx-a11y/no-access-key': 'error', ++ 'jsx-a11y/no-autofocus': 'error', ++ 'jsx-a11y/no-distracting-elements': 'error', ++ 'jsx-a11y/no-interactive-element-to-noninteractive-role': 'error', ++ 'jsx-a11y/no-noninteractive-element-interactions': [ ++ 'error', ++ { ++ body: ['onError', 'onLoad'], ++ iframe: ['onError', 'onLoad'], ++ img: ['onError', 'onLoad'], ++ }, ++ ], ++ 'jsx-a11y/no-noninteractive-element-to-interactive-role': 'error', ++ 'jsx-a11y/no-noninteractive-tabindex': 'error', ++ 'jsx-a11y/no-redundant-roles': 'error', ++ 'jsx-a11y/no-static-element-interactions': 'error', ++ 'jsx-a11y/role-has-required-aria-props': 'error', ++ 'jsx-a11y/role-supports-aria-props': 'error', ++ 'jsx-a11y/scope': 'error', ++ 'jsx-a11y/tabindex-no-positive': 'error', }, }, }; + ++/** @param {object} obj */ +function kebabCase(obj) { + return Object.fromEntries( -+ Object.entries(obj).map(([key, value]) => [key.replace(/([A-Z])/g, '-$1').toLowerCase(), value]) -+ ) ++ Object.entries(obj).map(([key, value]) => [ ++ key.replace(/([A-Z])/g, '-$1').toLowerCase(), ++ value, ++ ]), ++ ); +} -\ No newline at end of file diff --git a/patch/eslint-plugin-n.patch b/patch/eslint-plugin-n.patch new file mode 100644 index 0000000..a228eb7 --- /dev/null +++ b/patch/eslint-plugin-n.patch @@ -0,0 +1,25 @@ +diff --git a/lib/index.js b/lib/index.js +index 341c86d..3fb26d1 100644 +--- a/lib/index.js ++++ b/lib/index.js +@@ -1,15 +1,16 @@ + /* DON'T EDIT THIS FILE. This is generated by 'scripts/update-lib-index.js' */ + "use strict" + +-const pkg = require("../package.json") ++import { name, version } from "../package.json" ++import recommendedModule from "./configs/recommended-module" + + module.exports = { + meta: { +- name: pkg.name, +- version: pkg.version, ++ name, ++ version, + }, + configs: { +- "recommended-module": require("./configs/recommended-module"), ++ "recommended-module": recommendedModule, + "recommended-script": require("./configs/recommended-script"), + get recommended() { + return require("./configs/recommended")() diff --git a/patch/eslint-plugin-react.patch b/patch/eslint-plugin-react.patch index cb34d17..d9177c1 100644 --- a/patch/eslint-plugin-react.patch +++ b/patch/eslint-plugin-react.patch @@ -87,12 +87,12 @@ index 4991f200..00000000 - ], -} diff --git a/index.js b/index.js -index 4140c6c8..792ceb4f 100644 +index 4140c6c8..03e623af 100644 --- a/index.js +++ b/index.js -@@ -1,15 +1,13 @@ - 'use strict'; - +@@ -1,31 +1,25 @@ +-'use strict'; +- -const configAll = require('./configs/all'); -const configRecommended = require('./configs/recommended'); -const configRuntime = require('./configs/jsx-runtime'); @@ -102,16 +102,47 @@ index 4140c6c8..792ceb4f 100644 +import configRecommended from './configs/recommended'; +import configRuntime from './configs/jsx-runtime'; +import { name } from './package.json'; -+import allRules from './lib/rules'; ++export { default as rules } from './lib/rules'; // for legacy config system -const plugins = [ - 'react', -]; +const plugins = [name]; ++ ++export const deprecatedRules = configAll.plugins.react.deprecatedRules; - module.exports = { - deprecatedRules: configAll.plugins.react.deprecatedRules, +-module.exports = { +- deprecatedRules: configAll.plugins.react.deprecatedRules, +- rules: allRules, +- configs: { +- recommended: Object.assign({}, configRecommended, { +- parserOptions: configRecommended.languageOptions.parserOptions, +- plugins, +- }), +- all: Object.assign({}, configAll, { +- parserOptions: configAll.languageOptions.parserOptions, +- plugins, +- }), +- 'jsx-runtime': Object.assign({}, configRuntime, { +- parserOptions: configRuntime.languageOptions.parserOptions, +- plugins, +- }), +- }, ++export const configs = { ++ recommended: Object.assign({}, configRecommended, { ++ parserOptions: configRecommended.languageOptions.parserOptions, ++ plugins, ++ }), ++ all: Object.assign({}, configAll, { ++ parserOptions: configAll.languageOptions.parserOptions, ++ plugins, ++ }), ++ 'jsx-runtime': Object.assign({}, configRuntime, { ++ parserOptions: configRuntime.languageOptions.parserOptions, ++ plugins, ++ }), + }; diff --git a/lib/rules/button-has-type.js b/lib/rules/button-has-type.js index 204a33c4..01d992c2 100644 --- a/lib/rules/button-has-type.js @@ -253,7 +284,7 @@ index 55073bfe..efc07af1 100644 const astUtil = require('./ast'); const isCreateElement = require('./isCreateElement'); diff --git a/package.json b/package.json -index cb736434..a97113c0 100644 +index b1fa86fa..758b2177 100644 --- a/package.json +++ b/package.json @@ -25,21 +25,13 @@ @@ -273,12 +304,12 @@ index cb736434..a97113c0 100644 - "object.values": "^1.1.6", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.4", -- "semver": "^6.3.0", +- "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.8" -+ "semver": "^6.3.0" ++ "semver": "^6.3.1" }, "devDependencies": { - "@babel/core": "^7.21.0", + "@babel/core": "^7.22.9", diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 39187b7f..00000000 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4ffc39..6a7ab38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,12 +20,30 @@ devDependencies: '@types/babel__core': specifier: ^7.20.1 version: 7.20.1 + '@types/eslint': + specifier: ^8.44.0 + version: 8.44.0 + '@types/estree': + specifier: ^1.0.1 + version: 1.0.1 + '@types/estree-jsx': + specifier: ^1.0.0 + version: 1.0.0 + '@types/lodash': + specifier: ^4.14.195 + version: 4.14.195 '@types/node': specifier: ^20.4.2 version: 20.4.2 + '@typescript-eslint/types': + specifier: ^6.1.0 + version: 6.1.0 babel-plugin-macros: specifier: ^3.1.0 version: 3.1.0 + dts-bundle-generator: + specifier: ^8.0.1 + version: 8.0.1 esbin: specifier: 0.0.1-beta.1 version: 0.0.1-beta.1(esbuild@0.18.14) @@ -44,15 +62,30 @@ devDependencies: eslint-config-prettier: specifier: 8.8.0 version: 8.8.0(eslint@8.45.0) + eslint-define-config: + specifier: ^1.21.0 + version: 1.21.0 eslint-plugin-import: specifier: ^2.27.5 version: 2.27.5(eslint@8.45.0) glob: specifier: ^10.3.3 version: 10.3.3 + json-schema-to-ts: + specifier: ^2.9.1 + version: 2.9.1 + lodash: + specifier: ^4.17.21 + version: 4.17.21 + picocolors: + specifier: ^1.0.0 + version: 1.0.0 prettier: specifier: ^3.0.0 version: 3.0.0 + typescript: + specifier: 5.1.6 + version: 5.1.6 packages: @@ -1690,10 +1723,35 @@ packages: '@babel/types': 7.21.5 dev: true + /@types/eslint@8.44.0: + resolution: {integrity: sha512-gsF+c/0XOguWgaOgvFs+xnnRqt9GwgTvIks36WpE6ueeI4KCEHHd8K/CKHqhOqrJKsYH8m27kRzQEvWXAwXUTw==} + dependencies: + '@types/estree': 1.0.1 + '@types/json-schema': 7.0.12 + dev: true + + /@types/estree-jsx@1.0.0: + resolution: {integrity: sha512-3qvGd0z8F2ENTGr/GG1yViqfiKmRfrXVx5sJyHGFu3z7m5g5utCQtGp/g29JnjflhtQJBv1WDQukHiT58xPcYQ==} + dependencies: + '@types/estree': 1.0.1 + dev: true + + /@types/estree@1.0.1: + resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} + dev: true + + /@types/json-schema@7.0.12: + resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==} + dev: true + /@types/json5@0.0.29: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: true + /@types/lodash@4.14.195: + resolution: {integrity: sha512-Hwx9EUgdwf2GLarOjQp5ZH8ZmblzcbTBC2wtQWNKARBSxM9ezRIAUpeDTgoQRAFB0+8CNWXVA9+MaSOzOF3nPg==} + dev: true + /@types/node@20.4.2: resolution: {integrity: sha512-Dd0BYtWgnWJKwO1jkmTrzofjK2QXXcai0dmtzvIBhcA+RsG5h8R3xlyta0kGOZRNfL9GuRtb1knmPEhQrePCEw==} dev: true @@ -1702,6 +1760,11 @@ packages: resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} dev: true + /@typescript-eslint/types@6.1.0: + resolution: {integrity: sha512-+Gfd5NHCpDoHDOaU/yIF3WWRI2PcBRKKpP91ZcVbL0t5tQpqYWBs3z/GGhvU+EV1D0262g9XCnyqQh19prU0JQ==} + engines: {node: ^16.0.0 || >=18.0.0} + dev: true + /acorn-jsx@5.3.2(acorn@8.10.0): resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1911,6 +1974,15 @@ packages: supports-color: 7.2.0 dev: true + /cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + dev: true + /color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} dependencies: @@ -2015,6 +2087,15 @@ packages: esutils: 2.0.3 dev: true + /dts-bundle-generator@8.0.1: + resolution: {integrity: sha512-9JVw78/OXdKfq+RUrmpLm6WAUJp+aOUGEHimVqIlOEH2VugRt1I8CVIoQZlirWZko+/SVZkNgpWCyZubUuzzPA==} + engines: {node: '>=14.0.0'} + hasBin: true + dependencies: + typescript: 5.1.6 + yargs: 17.7.2 + dev: true + /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} dev: true @@ -2186,6 +2267,11 @@ packages: eslint: 8.45.0 dev: true + /eslint-define-config@1.21.0: + resolution: {integrity: sha512-OKfreV19Nw4yK4UX1CDkv5FXWdzeF+VSROsO28DVi1BrzqOD4a3U71LJqEhcupK65MoLXxARQ0pSg8bDvNPONA==} + engines: {node: ^16.13.0 || >=18.0.0, npm: '>=7.0.0', pnpm: '>= 8.6.0'} + dev: true + /eslint-import-resolver-node@0.3.7: resolution: {integrity: sha512-gozW2blMLJCeFpBwugLTGyvVjNoeo1knonXAcatC6bjPBZitotxdWf7Gimr25N4c0AAOo4eOUfaG82IJPDpqCA==} dependencies: @@ -2248,7 +2334,7 @@ packages: minimatch: 3.1.2 object.values: 1.1.6 resolve: 1.22.2 - semver: 6.3.0 + semver: 6.3.1 tsconfig-paths: 3.14.2 transitivePeerDependencies: - eslint-import-resolver-typescript @@ -2434,6 +2520,11 @@ packages: engines: {node: '>=6.9.0'} dev: true + /get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + dev: true + /get-intrinsic@1.2.1: resolution: {integrity: sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==} dependencies: @@ -2465,7 +2556,7 @@ packages: dependencies: foreground-child: 3.1.1 jackspeak: 2.2.1 - minimatch: 9.0.1 + minimatch: 9.0.3 minipass: 6.0.2 path-scurry: 1.10.1 dev: true @@ -2759,6 +2850,15 @@ packages: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} dev: true + /json-schema-to-ts@2.9.1: + resolution: {integrity: sha512-8MNpRGERlCUWYeJwsWkMrJ0MWzBz49dfqpG+n9viiIlP4othaahbiaNQZuBzmPxRLUhOv1QJMCzW5WE8nHFGIQ==} + engines: {node: '>=16'} + dependencies: + '@babel/runtime': 7.21.5 + '@types/json-schema': 7.0.12 + ts-algebra: 1.2.0 + dev: true + /json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} dev: true @@ -2807,6 +2907,10 @@ packages: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true + /lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + dev: true + /lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} dependencies: @@ -2824,8 +2928,8 @@ packages: brace-expansion: 1.1.11 dev: true - /minimatch@9.0.1: - resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==} + /minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} engines: {node: '>=16 || 14 >=14.17'} dependencies: brace-expansion: 2.0.1 @@ -3039,6 +3143,11 @@ packages: jsesc: 0.5.0 dev: true + /require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + dev: true + /resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -3079,11 +3188,6 @@ packages: is-regex: 1.1.4 dev: true - /semver@6.3.0: - resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==} - hasBin: true - dev: true - /semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -3226,6 +3330,10 @@ packages: engines: {node: '>=4'} dev: true + /ts-algebra@1.2.0: + resolution: {integrity: sha512-kMuJJd8B2N/swCvIvn1hIFcIOrLGbWl9m/J6O3kHx9VRaevh00nvgjPiEGaRee7DRaAczMYR2uwWvXU22VFltw==} + dev: true + /tsconfig-paths@3.14.2: resolution: {integrity: sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==} dependencies: @@ -3264,6 +3372,12 @@ packages: is-typed-array: 1.1.10 dev: true + /typescript@5.1.6: + resolution: {integrity: sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==} + engines: {node: '>=14.17'} + hasBin: true + dev: true + /unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} dependencies: @@ -3365,6 +3479,11 @@ packages: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} dev: true + /y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + dev: true + /yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} dev: true @@ -3374,6 +3493,24 @@ packages: engines: {node: '>= 6'} dev: true + /yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + dev: true + + /yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + dependencies: + cliui: 8.0.1 + escalade: 3.1.1 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + dev: true + /yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} diff --git a/save_patch.sh b/save_patch.sh index e1a8d8b..d6792cc 100755 --- a/save_patch.sh +++ b/save_patch.sh @@ -6,4 +6,5 @@ sync() ( sync eslint-plugin-import sync eslint-plugin-jsx-a11y sync eslint-plugin-react +sync eslint-plugin-n sync jsx-ast-utils diff --git a/src/addAlias.ts b/src/addAlias.ts new file mode 100644 index 0000000..a59a1cb --- /dev/null +++ b/src/addAlias.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import { resolve } from 'node:path'; +import { name } from '../dist/package.json'; + +const pkgPath = resolve(process.cwd(), 'package.json'); +const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + +pkg.devDependencies ??= {}; + +Object.assign(pkg.devDependencies, { + 'eslint-plugin-import': `file:./node_modules/${name}/import`, + 'eslint-plugin-jsx-a11y': `file:./node_modules/${name}/jsx-a11y`, + 'eslint-plugin-local': `file:./node_modules/${name}/local`, + 'eslint-plugin-rules': `file:./node_modules/${name}/rules`, + 'eslint-plugin-react': `file:./node_modules/${name}/react`, + 'eslint-plugin-react-hooks': `file:./node_modules/${name}/react-hooks`, +}); + +fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2)); diff --git a/src/babel.ts b/src/babel.ts deleted file mode 100644 index ea902ad..0000000 --- a/src/babel.ts +++ /dev/null @@ -1,156 +0,0 @@ -import assert from 'node:assert'; -import { readFileSync } from 'node:fs'; -import { extname } from 'node:path'; -import * as babel from '@babel/core'; -import type { types as t } from '@babel/core'; -import type { Loader, Plugin } from 'esbuild'; -import { createMacro, type MacroHandler } from 'babel-plugin-macros'; - -class HandlerMap { - map = new Map(); - - set(names: string | string[], handler: MacroHandler) { - names = Array.isArray(names) ? names : [names]; - const macro = createMacro(handler); - for (const name of names) { - this.map.set(name, macro); - } - return this; - } - - get keys() { - return Array.from(this.map.keys()); - } - - resolvePath = (module: string) => module; - require = (module: string) => this.map.get(module); - isMacrosName = (module: string) => this.map.has(module); -} - -const map = new HandlerMap() - .set( - 'object.assign', - replace(t => t.memberExpression(t.identifier('Object'), t.identifier('assign'))), - ) - .set( - ['object-values', 'object.values'], - replace(t => t.memberExpression(t.identifier('Object'), t.identifier('values'))), - ) - .set( - 'object.fromentries', - replace(t => t.memberExpression(t.identifier('Object'), t.identifier('fromEntries'))), - ) - .set( - 'object.entries', - replace(t => t.memberExpression(t.identifier('Object'), t.identifier('entries'))), - ) - .set( - 'has', - replace(t => t.memberExpression(t.identifier('Object'), t.identifier('hasOwn'))), - ) - .set( - 'array-includes', - proto(t => t.identifier('includes')), - ) - .set( - 'array.prototype.flatmap', - proto(t => t.identifier('flatMap')), - ) - .set( - 'array.prototype.flat', - proto(t => t.identifier('flat')), - ) - .set( - 'array.prototype.findlastindex', - proto(t => t.identifier('findLastIndex')), - ) - .set( - 'array.prototype.tosorted', - proto(t => t.identifier('toSorted')), - ) - .set( - 'string.prototype.matchall', - proto(t => t.identifier('matchAll')), - ); - -function replace(getReplacement: (types: typeof t) => t.Expression): MacroHandler { - return ({ references, babel: { types: t } }) => { - references.default.forEach(referencePath => { - referencePath.replaceWith(getReplacement(t)); - }); - }; -} - -function proto(getProperty: (types: typeof t) => t.Expression): MacroHandler { - return ({ references, babel: { types: t } }) => { - references.default.forEach(referencePath => { - const { parent, parentPath } = referencePath; - assert(t.isCallExpression(parent)); - const [callee, ...rest] = parent.arguments; - parentPath!.replaceWith( - t.callExpression( - t.memberExpression(callee as t.Expression, getProperty(t)), - rest, - ), - ); - }); - }; -} - -export const babelPlugin: Plugin = { - name: 'babel', - setup(build) { - const { keys, ...macroOptions } = map; - - build.onLoad({ filter: /\.[jt]sx?$/ }, args => { - const { path } = args; - if (path.includes('node_modules/')) { - return null; - } - - let source = readFileSync(path, 'utf-8') - .replaceAll("require('object.hasown/polyfill')()", 'Object.hasOwn') - .replaceAll("require('object.fromentries/polyfill')()", 'Object.fromEntries'); - - if ( - path.includes('eslint-plugin-import/src/rules/') || - path.includes('eslint-plugin-import/config/') - ) { - source = source.replace('\nmodule.exports = {', '\nexport default {'); - } - - const isFlow = source.includes('@flow'); - const loader = extname(path).slice(1) as Loader; - - if (!isFlow && !keys.some(key => source.includes(key))) { - return { contents: source, loader }; - } - - const res = babel.transformSync(source, { - filename: path, - babelrc: false, - configFile: false, - parserOpts: { - plugins: [isFlow ? 'flow' : 'typescript'], - }, - plugins: [ - isFlow && '@babel/plugin-transform-flow-strip-types', - ['babel-plugin-macros', macroOptions], - ].filter(Boolean), - })!; - - return { - contents: res.code!, - loader, - }; - }); - }, -}; - -declare global { - interface Array { - filter( - predicate: BooleanConstructor, - ): Exclude[]; - } -} diff --git a/src/basic.ts b/src/basic.ts new file mode 100644 index 0000000..5785614 --- /dev/null +++ b/src/basic.ts @@ -0,0 +1,131 @@ +// @ts-check +import type { ESLintConfig } from 'eslint-define-config'; + +export function extendConfig({ + plugins, + settings, + rules, + ...config +}: ESLintConfig): ESLintConfig { + return { + root: true, + parser: '@typescript-eslint/parser', + plugins: ['@typescript-eslint', 'import', ...(plugins ?? [])], + env: { node: true, browser: true }, + reportUnusedDisableDirectives: true, + parserOptions: { project: ['./tsconfig.json'] }, + extends: [ + 'eslint:recommended', + 'prettier', + 'plugin:@typescript-eslint/recommended', + 'plugin:import/errors', + 'plugin:import/typescript', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', + 'plugin:jsx-a11y/recommended', + ], + settings: { + 'import/parsers': { + '@typescript-eslint/parser': ['.ts', '.tsx'], + }, + 'import/core-modules': ['node:test'], + 'import/resolver': { + typescript: { + alwaysTryTypes: true, + project: './tsconfig.json', + }, + }, + react: { + version: 'detect', + }, + ...settings, + }, + rules: { + 'no-duplicate-imports': 'error', + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'crypto', + importNames: ['webcrypto'], + message: 'Use global `crypto` instead', + }, + ], + }, + ], + 'no-restricted-globals': ['error', 'event', 'name'], + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/consistent-type-imports': [ + 'error', + { disallowTypeAnnotations: false, fixStyle: 'inline-type-imports' }, + ], + '@typescript-eslint/ban-types': [ + 'error', + { + extendDefaults: false, + types: { + String: { message: 'Use string instead', fixWith: 'string' }, + Number: { message: 'Use number instead', fixWith: 'number' }, + Boolean: { message: 'Use boolean instead', fixWith: 'boolean' }, + Symbol: { message: 'Use symbol instead', fixWith: 'symbol' }, + }, + }, + ], + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-namespace': 'off', + '@typescript-eslint/no-non-null-assertion': 'off', + '@typescript-eslint/no-use-before-define': 'off', + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/triple-slash-reference': 'off', + '@typescript-eslint/no-empty-interface': 'off', + '@typescript-eslint/no-unnecessary-type-assertion': 'error', + '@typescript-eslint/no-unused-vars': [ + 'error', + { ignoreRestSiblings: true, varsIgnorePattern: '^_' }, + ], + 'arrow-body-style': ['error', 'as-needed'], + 'class-methods-use-this': 'off', + complexity: ['warn', { max: 100 }], + curly: ['error', 'multi-line', 'consistent'], + eqeqeq: ['error', 'smart'], + 'no-async-promise-executor': 'off', + 'no-case-declarations': 'off', + 'no-console': 'warn', + 'no-constant-condition': ['error', { checkLoops: false }], + 'no-debugger': 'off', + 'no-empty': ['error', { allowEmptyCatch: true }], + 'no-inner-declarations': 'off', + 'no-lonely-if': 'error', + 'no-template-curly-in-string': 'error', + 'no-var': 'error', + 'import/export': 'off', + 'import/order': ['error', { groups: ['builtin', 'external'] }], + 'object-shorthand': ['error', 'always', { ignoreConstructors: true }], + 'one-var': ['error', { var: 'never', let: 'never' }], + 'prefer-arrow-callback': 'error', + 'prefer-const': ['error', { destructuring: 'all' }], + 'prefer-destructuring': [ + 'warn', + { AssignmentExpression: { array: false, object: false } }, + ], + 'prefer-object-spread': 'error', + 'prefer-rest-params': 'warn', + 'prefer-spread': 'warn', + 'quote-props': ['error', 'as-needed'], + 'spaced-comment': ['error', 'always', { markers: ['/'] }], + 'sort-imports': ['warn', { ignoreDeclarationSort: true }], + yoda: ['error', 'never', { exceptRange: true }], + 'react/display-name': 'off', + 'react/no-children-prop': 'error', + 'react/prop-types': 'off', + 'react/react-in-jsx-scope': 'off', + 'react/no-unknown-property': ['error', { ignore: ['css'] }], + ...rules, + }, + ...config, + }; +} diff --git a/src/build-local-rules.ts b/src/build-local-rules.ts new file mode 100755 index 0000000..a8a5c2c --- /dev/null +++ b/src/build-local-rules.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env -S node -r esbin +import { readdirSync, writeFileSync } from 'node:fs'; +import { camelCase } from 'lodash'; + +const files = readdirSync('./src/rules') + .filter(file => file.endsWith('.ts')) + .filter(file => file !== 'index.ts') + .map(file => file.slice(0, -3)); + +const entryFile = ` +import type { Rule } from 'eslint'; + +${files.map(file => `import ${camelCase(file)} from "./${file}"`).join(';\n')} + +export const rules: Record = { + ${files.map(file => `"${file}": ${camelCase(file)}`).join(',\n ')} +}; +`.trim(); + +writeFileSync('./src/rules/index.ts', entryFile); diff --git a/src/local/index.ts b/src/local/index.ts new file mode 100644 index 0000000..2d0ceb7 --- /dev/null +++ b/src/local/index.ts @@ -0,0 +1,29 @@ +import type { ESLint } from 'eslint'; +import * as fs from 'node:fs'; +import { resolve, basename, extname } from 'node:path'; + +function tryRequire(candidates: string[]) { + for (const candidate of candidates) { + try { + require(candidate); + return; + } catch {} + } +} + +tryRequire(['esbin', 'esbuild-register', 'ts-node/register/transpile-only']); + +const folders = resolve(process.cwd(), 'eslint-local-rules'); +const files = fs.readdirSync(folders); + +const plugin: ESLint.Plugin = { + rules: {}, +}; + +for (const file of files) { + const name = basename(file, extname(file)); + const module = require(resolve(folders, file)); + plugin.rules![name] = module.default ?? module; +} + +export = plugin; diff --git a/src/rules/index.ts b/src/rules/index.ts new file mode 100644 index 0000000..f052131 --- /dev/null +++ b/src/rules/index.ts @@ -0,0 +1,9 @@ +import type { Rule } from 'eslint'; + +import noImportDot from "./no-import-dot"; +import requireNodePrefix from "./require-node-prefix" + +export const rules: Record = { + "no-import-dot": noImportDot, + "require-node-prefix": requireNodePrefix +}; \ No newline at end of file diff --git a/src/rules/no-new-prisma.ts b/src/rules/no-new-prisma.ts deleted file mode 100644 index a53f4b6..0000000 --- a/src/rules/no-new-prisma.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Rule } from "eslint"; - -const rule: Rule.RuleModule = { - meta: { - type: "problem", - docs: { - description: "Disallow direct usage of `new PrismaClient()`", - category: "Best Practices", - recommended: true, - }, - }, - create(context) { - // Check if the file is the target file where the import is allowed - if (context.filename.endsWith("src/utils/db.ts")) { - return {}; - } - - return { - NewExpression(node) { - if (node.callee.type === "Identifier" && node.callee.name === "PrismaClient") { - context.report({ - node, - message: - "Avoid direct usage of `new PrismaClient()`. Import from `src/utils/db.ts` instead.", - }); - } - }, - }; - }, -}; - -export default rule; diff --git a/src/rules/no-webcrypto-import.ts b/src/rules/no-webcrypto-import.ts deleted file mode 100644 index d2926eb..0000000 --- a/src/rules/no-webcrypto-import.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { Rule } from "eslint"; - -const rule: Rule.RuleModule = { - meta: { - type: "problem", - docs: { - description: "Disallow importing webcrypto from node:crypto and crypto modules", - category: "Best Practices", - recommended: true, - }, - schema: [], - }, - create: context => ({ - ImportDeclaration(node) { - const importedSource = node.source.value as string; - const importedSpecifier = node.specifiers[0]; - - if ( - (importedSource === "crypto" || importedSource === "node:crypto") && - importedSpecifier.type === "ImportSpecifier" && - importedSpecifier.local.name === "webcrypto" - ) { - context.report({ - node: importedSpecifier, - message: - "Do not import 'webcrypto' from 'crypto' or 'node:crypto'. Use the global variable 'crypto' instead.", - }); - } - }, - }), -}; - -export default rule; diff --git a/tsconfig.json b/tsconfig.json index 7a68fba..3af4e21 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,14 @@ { "compilerOptions": { + "allowJs": true, "allowArbitraryExtensions": true, + "declaration": true, + "emitDeclarationOnly": true, "esModuleInterop": true, "experimentalDecorators": true, "jsx": "react-jsx", "module": "commonjs", - "moduleResolution": "bundler", + "moduleResolution": "node", "noImplicitOverride": true, "noUnusedLocals": true, "noUnusedParameters": true,