Skip to content

Commit

Permalink
chore: rewrite eslint plugin in typescript (#96)
Browse files Browse the repository at this point in the history
* chore: rewrite eslint plugin in typescript

* fix: build before lint step because it is required for doc generation

* docs: add build step in docs on how to generate eslint docs

* fix: add dist to eslintignore

* chore: remove eslint plugin node and use typescript eslint plugin

* chore: add eslint prettier plugin
  • Loading branch information
pierrezimmermannbam authored Nov 13, 2023
1 parent 6f9925b commit 215e768
Show file tree
Hide file tree
Showing 25 changed files with 909 additions and 326 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ jobs:
- name: Install dependencies
run: yarn --immutable

- name: Build
run: yarn build

- name: Set up .npmrc file
run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/quality-eslint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ jobs:
- name: Install dependencies
run: yarn --frozen-lockfile

- name: Build
run: yarn build

- name: Linter
run: yarn lint

Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
node_modules/
node_modules/
dist
12 changes: 0 additions & 12 deletions packages/eslint-plugin/.eslint-doc-generatorrc.js

This file was deleted.

11 changes: 11 additions & 0 deletions packages/eslint-plugin/.eslint-doc-generatorrc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import prettier from "prettier";
import type { GenerateOptions } from "eslint-doc-generator";

const config: GenerateOptions = {
postprocess: (content, path) =>
prettier.format(content, { parser: "markdown" }),
urlRuleDoc: (path) =>
`https://github.com/bamlab/react-native-project-config/blob/main/packages/eslint-plugin/docs/rules/${path}.md`,
};

export default config;
1 change: 1 addition & 0 deletions packages/eslint-plugin/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/dist
11 changes: 4 additions & 7 deletions packages/eslint-plugin/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ module.exports = {
extends: [
"eslint:recommended",
"plugin:eslint-plugin/recommended",
"plugin:node/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended",
],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
rules: {
"eslint-plugin/require-meta-docs-description": [
2,
Expand All @@ -24,11 +27,5 @@ module.exports = {
env: {
node: true,
},
overrides: [
{
files: ["tests/**/*.js"],
env: { mocha: true },
},
],
ignorePatterns: ["example-app"],
};
1 change: 1 addition & 0 deletions packages/eslint-plugin/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ Creating new rules is quite simple:
1. Update the README and run the tests:
```bash
yarn build
yarn update:eslint-docs
yarn lint
yarn test
Expand Down
14 changes: 14 additions & 0 deletions packages/eslint-plugin/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Config } from "jest";

const config: Config = {
verbose: true,
preset: "ts-jest",
testEnvironment: "node",
transform: {
"^.+\\.tsx?$": "ts-jest",
},
testRegex: "(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$",
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
};

export default config;
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
// @ts-check
"use strict";
import { defineConfig } from "eslint-define-config";

const { defineConfig } = require("eslint-define-config");

module.exports = defineConfig({
export const a11yconfig = defineConfig({
extends: ["plugin:react-native-a11y/all"],
rules: {
"react-native-a11y/has-accessibility-hint": "off",
Expand Down
9 changes: 9 additions & 0 deletions packages/eslint-plugin/lib/configs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { a11yconfig } from "./a11y";
import { recommendedConfig } from "./recommended";
import { testsConfig } from "./tests";

export default {
recommended: recommendedConfig,
tests: testsConfig,
a11y: a11yconfig,
};
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
// @ts-check
"use strict";
import { defineConfig } from "eslint-define-config";

const { defineConfig } = require("eslint-define-config");

module.exports = defineConfig({
export const recommendedConfig = defineConfig({
ignorePatterns: [
".cache", // tsc/eslint/metro cache
".expo-shared",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
// @ts-check
"use strict";
import { defineConfig } from "eslint-define-config";

const { defineConfig } = require("eslint-define-config");

module.exports = defineConfig({
export const testsConfig = defineConfig({
env: {
"jest/globals": true,
},
Expand Down
8 changes: 0 additions & 8 deletions packages/eslint-plugin/lib/index.js

This file was deleted.

4 changes: 4 additions & 0 deletions packages/eslint-plugin/lib/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import configs from "./configs";
import rules from "./rules";

export { configs, rules };
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,10 @@
* @fileoverview Makes sure userEvent.press and userEvent.type are awaited
* @author Pierre Zimmermann
*/
"use strict";

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
import type { Rule } from "eslint";

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
export const awaitUserEventRule: Rule.RuleModule = {
meta: {
type: "problem",
docs: {
Expand All @@ -30,6 +26,7 @@ module.exports = {
CallExpression(node) {
if (
node.callee.type === "MemberExpression" &&
"name" in node.callee.object &&
node.callee.object.name === "userEvent"
) {
// Check if the parent is not an AwaitExpression
Expand Down
9 changes: 9 additions & 0 deletions packages/eslint-plugin/lib/rules/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { awaitUserEventRule } from "./await-user-event";
import { preferUserEventRule } from "./prefer-user-event";
import { requireNamedEffectRule } from "./require-named-effect";

export default {
"await-user-event": awaitUserEventRule,
"prefer-user-event": preferUserEventRule,
"require-named-effect": requireNamedEffectRule,
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,9 @@
* @fileoverview Forces usage of userEvent.press over fireEvent.press and userEvent.type over fireEvent.changeText
* @author Pierre Zimmermann
*/
"use strict";
import type { Rule } from "eslint";

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
export const preferUserEventRule: Rule.RuleModule = {
meta: {
type: "problem",
docs: {
Expand All @@ -20,7 +15,8 @@ module.exports = {
},
messages: {
replacePress: "Replace `fireEvent.press` with `await userEvent.press.`",
replaceChangeText: "Replace `fireEvent.changeText` with `await userEvent.type.`",
replaceChangeText:
"Replace `fireEvent.changeText` with `await userEvent.type.`",
},
fixable: "code",
schema: [],
Expand All @@ -29,8 +25,8 @@ module.exports = {
create(context) {
return {
MemberExpression: (node) => {
if (node.object.name === "fireEvent") {
if (node.property.name === "press") {
if ("name" in node.object && node.object.name === "fireEvent") {
if ("name" in node.property && node.property.name === "press") {
context.report({
node: node.property,
messageId: "replacePress",
Expand All @@ -41,7 +37,10 @@ module.exports = {
];
},
});
} else if (node.property.name === "changeText") {
} else if (
"name" in node.property &&
node.property.name === "changeText"
) {
context.report({
node: node.property,
messageId: "replaceChangeText",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,18 @@
// Rule Definition
//------------------------------------------------------------------------------

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
import type { Rule } from "eslint";
import { CallExpression } from "estree";

export const requireNamedEffectRule: Rule.RuleModule = {
meta: {
type: "suggestion", // `problem`, `suggestion`, or `layout`
docs: {
description: "Enforces the use of named functions inside a useEffect",
recommended: false,
url: "https://github.com/bamlab/react-native-project-config/tree/main/packages/eslint-plugin/docs/rules/require-named-effect.md", // URL to the documentation page for this rule
},
fixable: null, // Or `code` or `whitespace`
fixable: undefined, // Or `code` or `whitespace`
schema: [], // Add a schema if the rule has options,
messages: {
useNamedFunction: "Complex effects must be a named function.",
Expand All @@ -29,13 +31,20 @@ module.exports = {
// Helpers
//----------------------------------------------------------------------

const isUseEffect = (node) => node.callee.name === "useEffect";

const argumentIsArrowFunction = (node) =>
node.arguments[0].type === "ArrowFunctionExpression";
const argumentIsArrowFunction = (
node: CallExpression & Rule.NodeParentExtension,
) => {
return node.arguments[0].type === "ArrowFunctionExpression";
};

const effectBodyIsSingleFunction = (node) => {
const { body } = node.arguments[0];
const effectBodyIsSingleFunction = (
node: CallExpression & Rule.NodeParentExtension,
) => {
const firstArg = node.arguments[0];
if (!("body" in firstArg)) {
return false;
}
const { body } = firstArg;

// It's a single unwrapped function call:
// `useEffect(() => theNameOfAFunction(), []);`
Expand All @@ -48,9 +57,12 @@ module.exports = {
// theOnlyChildIsAFunctionCall();
// }, []);`
if (
"body" in body &&
"length" in body.body &&
body.body.length &&
body.body.length === 1 &&
body.body[0] &&
"expression" in body.body[0] &&
body.body[0].expression &&
body.body[0].expression.type === "CallExpression"
) {
Expand All @@ -60,21 +72,19 @@ module.exports = {
return false;
};

const fail = (report, node) =>
report({ node, messageId: "useNamedFunction" });

//----------------------------------------------------------------------
// Public
//----------------------------------------------------------------------

return {
CallExpression(node) {
if (
isUseEffect(node) &&
"name" in node.callee &&
node.callee.name === "useEffect" &&
argumentIsArrowFunction(node) &&
!effectBodyIsSingleFunction(node)
) {
fail(context.report, node);
context.report({ node, messageId: "useNamedFunction" });
}
},
};
Expand Down
2 changes: 1 addition & 1 deletion packages/eslint-plugin/lib/utils/hasLabelProp.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module.exports = (element) => {
return (
attribute.type === "JSXAttribute" &&
["accessibilityLabel", "aria-label", "alt"].includes(
attribute.name.name
attribute.name.name,
)
);
});
Expand Down
8 changes: 4 additions & 4 deletions packages/eslint-plugin/lib/utils/isAccessible.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const isImage = require("./isImage");
const isPressable = require("./isPressable");
const isText = require("./isText");
const isTextInput = require("./isTextInput");
import isImage from "./isImage";
import isPressable from "./isPressable";
import isText from "./isText";
import isTextInput from "./isTextInput";

module.exports = (element) => {
if (element.type === "JSXOpeningElement") {
Expand Down
Loading

0 comments on commit 215e768

Please sign in to comment.