This commit is contained in:
Alex 2023-07-22 01:00:28 -04:00
parent 77444efbf2
commit 3debdb9e74
27 changed files with 1756 additions and 585 deletions

2
.gitignore vendored
View File

@ -2,7 +2,7 @@ eslint-plugin-import
eslint-plugin-jsx-a11y
eslint-plugin-react
jsx-ast-utils
react
/react
dist/**/*.js
dist/**/*.js.map

View File

@ -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

2
dist/basic.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
import type { ESLintConfig } from 'eslint-define-config';
export declare function extendConfig({ plugins, settings, rules, ...config }: ESLintConfig): ESLintConfig;

6
dist/package.json vendored
View File

@ -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",

12
dist/react-hooks/index.d.ts vendored Normal file
View File

@ -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;
};

View File

@ -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<string, MacroHandler>();
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<T> {
filter(
predicate: BooleanConstructor,
): Exclude<T, null | undefined | false | '' | 0>[];
}
}
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');

View File

@ -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<typeof schema>;
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<Expression, Pattern>();
const stateVariables = new WeakSet<Identifier>();
const stableKnownValueCache = new WeakMap<Scope.Variable, boolean>();
const functionWithoutCapturedValueCache = new WeakMap<Scope.Variable, boolean>();
const useEffectEventVariables = new WeakSet();
function memoizeWithWeakMap(fn, map) {
function memoizeWithWeakMap<T extends object, R>(
fn: (v: T) => R,
map: WeakMap<T, R>,
): (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<Scope.Scope>();
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<string, Dependencies>();
const optionalChains = new Map<string, boolean>();
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 = <something>
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<string>();
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<string>();
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<string>();
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<string>,
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<string, DepTree>;
}
// The meat of the logic.
function collectRecommendations({
dependencies,
@ -1273,7 +1333,18 @@ function collectRecommendations({
stableDependencies,
externalDependencies,
isEffect,
}) {
}: {
dependencies: Map<string, Dependencies>;
declaredDependencies: DeclaredDependency[];
stableDependencies: Set<string>;
externalDependencies: Set<string>;
isEffect: boolean;
}): {
suggestedDependencies: string[];
unnecessaryDependencies: Set<string>;
duplicateDependencies: Set<string>;
missingDependencies: Set<string>;
} {
// 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<string, never>(),
};
}
@ -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<string>();
const satisfyingDependencies = new Set<string>();
scanTreeRecursively(depTree, missingDependencies, satisfyingDependencies, key => key);
function scanTreeRecursively(node, missingPaths, satisfyingPaths, keyToPath) {
function scanTreeRecursively(
node: DepTree,
missingPaths: Set<string>,
satisfyingPaths: Set<string>,
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<string>();
const duplicateDependencies = new Set<string>();
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<string, boolean> | 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<string, boolean> | 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;

View File

@ -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<Identifier>();
// 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<string>,
) {
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<string>,
): 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<string, bigint>();
countPathsToEnd.cache = new Map<string, bigint>();
shortestPathLengthToStart.cache = new Map<string, number | null>();
// 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;

View File

@ -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 = {

View File

@ -0,0 +1,12 @@
{
"upstream": {
"version": 1,
"sources": {
"main": {
"repository": "git@github.com:facebook/react.git",
"commit": "899cb95f52cc83ab5ca1eb1e268c909d3f0961e7",
"branch": "main"
}
}
}
}

65
eslint-plugin-react-hooks/types.d.ts vendored Normal file
View File

@ -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)[];
}
}

View File

@ -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",

View File

@ -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<import('eslint').Linter.RulesRecord>}
+ */
+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(

View File

@ -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

View File

@ -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")()

View File

@ -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

155
pnpm-lock.yaml generated
View File

@ -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'}

View File

@ -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

20
src/addAlias.ts Normal file
View File

@ -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));

View File

@ -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<string, MacroHandler>();
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<T> {
filter(
predicate: BooleanConstructor,
): Exclude<T, null | undefined | false | '' | 0>[];
}
}

131
src/basic.ts Normal file
View File

@ -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,
};
}

20
src/build-local-rules.ts Executable file
View File

@ -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<string, Rule.RuleModule> = {
${files.map(file => `"${file}": ${camelCase(file)}`).join(',\n ')}
};
`.trim();
writeFileSync('./src/rules/index.ts', entryFile);

29
src/local/index.ts Normal file
View File

@ -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;

9
src/rules/index.ts Normal file
View File

@ -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<string, Rule.RuleModule> = {
"no-import-dot": noImportDot,
"require-node-prefix": requireNodePrefix
};

View File

@ -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;

View File

@ -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;

View File

@ -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,