Table of Contents Initialize repo using a template Initial structure of a template Adding rules using scripts from a template Write tests for the Eslint plugin Write Eslint rule Small AST explanation Final variant Updating docs using scripts Plugin publishing Connect it with your application Background I will try to write a tutorial based on my PR in the Reatom repository with a step-by-step explanation: https://github.com/artalar/Reatom/pull/488 If you want to know more, you can read the issue https://github.com/artalar/reatom/issues/487. To add a bit of context, Reatom is a state management library. Atoms are a concept in Reatom, a state management library for React. What Are the ESLint Plugins and Rules? ESLint plugins are extensions that work with the core ESLint package to enforce specific coding standards. Plugins contain a folder, which defines individual rules for enforcing these standards. rules Each module has a property that describes the and a property that defines the behavior. rule meta rule create rule's The function takes a argument, which is used to interact with the code being checked, and you can use it to define the logic of your rule, like requiring strict naming conventions for your library. create context Let’s Dive Into the Code Initialize Repo Creating a new TypeScript eslint project npx degit https://github.com/pivaszbs/typescript-template-eslint-plugin reatom-eslint-plugin Then, navigate to the new project directory, and install the dependencies with: cd reatom-eslint-plugin && npm i I want to be a good boy, so I init git. git init && git add . && git commit -m "init" Next, open the file, and locate the field. This field is essential because it will be the main entry point for your plugin when it's used. You can change it to the following: package.json name "name": "eslint-plugin-reatom" Alternatively, you can use the scoped package naming convention: "name": "@reatom/eslint-plugin" Initial Structure - scripts // some automation to concentrate on writing rules - docs - rules // here will be generated by npm run add-rule files - src - configs recommended.ts // generated config - rules // all your rules index.ts // Connection point to your plugin, autogenerated by scripts/lib/update-lib-index.ts 😀 In general index, files will be generated by scripts, so you don’t need to worry about it /* DON'T EDIT THIS FILE. This is generated by 'scripts/lib/update-lib-index.ts' */ import { recommended } from './configs/recommended'; import exampleRule from './rules/example-rule' export const configs = { recommended }; export const rules = { 'example-rule': exampleRule }; Adding Rules and Updating Docs In this repository, you'll find some convenient scripts for adding rules and updating documents. To add a new rule, you can use the following command: npm run add-rule atom-rule suggestion This will generate three sections for the new rule: documentation, tests, and actual code. We can skip the documentation section for now and focus on the last two. Write Tests As a TDD (test-driven development) enthusiast, we'll start by creating some simple tests in the file: tests/atom-rule.ts // tests/atom-rule.ts tester.run('atom-rule', atomRule, { valid: [ { code: 'const countAtom = atom(0, "countAtom");' }, ], invalid: [ { code: `const countAtom = atom(0);`, errors: [{ message: 'atom name is not defined' }] }, { code: 'const countAtom = atom(0, "count");', errors: [{ message: 'atom name is defined bad'}] }, ] }); If you run the tests now, they will fail because we haven't implemented the yet. atomRule Writing the Rule The is where we define the rule's behavior. Here's a simple implementation: atomRule import { Rule } from "eslint"; const rule: Rule.RuleModule = { meta: { docs: { description: "Add name for every atom call", // simply describe your rule recommended: true, // if it's recommended, then npm run update will add it to recommmended config }, type: "suggestion" }, create: function (context: Rule.RuleContext): Rule.RuleListener { return { VariableDeclaration: node => { // listener for declaration, here we can specifiy more specific selector node.declarations.forEach(d => { if (d.init?.type !== 'CallExpression') return; if (d.init.callee.type !== 'Identifier') return; if (d.init.callee.name !== 'atom') return; if (d.id.type !== 'Identifier') return; // just guard everything that we don't need if (d.init.arguments.length <= 1) { // show error in user code context.report({ message: `atom name is not defined`, // here we can pass what will be underlined by red/yellow line node, }) } if (d.init.arguments[1]?.type !== 'Literal') return; // just another guard if (d.init.arguments[1].value !== d.id.name) { context.report({ message: `atom name is defined bad`, node }) } }) } }; }, }; export default rule; It’s a simple variant, but here, we can easily understand what’s going on. For a better understanding of the AST structure of your code, you can use or simply console.log parsed nodes. https://astexplorer.net/ A Small Explanation for AST Typings Better Understanding Here's a small description of each identifier in a small example: const kek = atom(‘kek’) : a TypeScript interface that represents an identifier node in an AST. Identifier const = (‘kek’), and are Identifiers nodes. kek atom kek, atom : a TypeScript interface that represents a literal value (string, number, boolean, etc.) node in an AST. const kek = atom(‘ ’), ‘kek’ is a Literal kek Literal. : a TypeScript interface that represents a function call expression node in an abstract syntax tree (AST). CallExpression In our example, is a CallExpression, which consists of atom(‘kek’) atom - Identifier and kek - Literal. : a TypeScript interface that represents a variable declarator node in an AST VariableDeclarator In our example, the whole expression except const is VariableDeclarator kek = atom(‘kek’) : a TypeScript interface that represents a generic AST node. Node Or simply using astexplorer https://astexplorer.net/?embedable=true#/gist/7fe145026f1b15adefeb307427210d38/35f114eb5b9c4d3cb626e76aa6af7782927315ed Final Variant The final tests tester.run('atom-rule', rule, { valid: [ { code: ` import { atom } from '@reatom/framework' const countAtom = atom(0, "countAtom"); ` }, { code: `const countAtom = atom(0);`, }, { code: 'const countAtom = atom(0, "count");', }, ], invalid: [ { code: ` import { atom } from '@reatom/framework' const countAtom = atom(0); `, errors: [{ message: 'atom "countAtom" should has a name inside atom() call', }], output: ` import { atom } from '@reatom/framework' const countAtom = atom(0, "countAtom"); `, }, { code: ` import { atom } from '@reatom/framework' const countAtom = atom(0, "count"); `, errors: [{ message: `atom "countAtom" should be named as it's variable name, rename it to "countAtom"` }], output: ` import { atom } from '@reatom/framework' const countAtom = atom(0, "countAtom"); `, }, ] }); From tests, we understand that we need somehow change the source code by using our rule. How to Make Your Rule Fixable? Add a simple line to the context report. fix: fixer => fixer.replaceText(node, replaceString) - may be an actual node or range of symbols that you want to replace. node - what code you expect to see. replaceString Don’t forget to add : 'code' or : 'whitespace' for your rule meta tags. fixable fixable If you are not familiar with how to fix it with eslint, just try on your existing project. eslint --fix ./src Code Itself import { Rule } from "eslint"; import { CallExpression, Identifier, Literal, VariableDeclarator, Node } from 'estree'; import { isIdentifier, isLiteral } from "../lib"; type AtomCallExpression = CallExpression & { callee: Identifier, arguments: [Literal] | [Literal, Literal] } type AtomVariableDeclarator = VariableDeclarator & { id: Identifier, init: AtomCallExpression } const noname = (atomName: string) => `atom "${atomName}" should has a name inside atom() call`; const invalidName = (atomName: string) => `atom "${atomName}" should be named as it's variable name, rename it to "${atomName}"`; export const atomRule: Rule.RuleModule = { meta: { type: 'suggestion', docs: { recommended: true, description: "Add name for every atom call" }, fixable: 'code' }, create: function (context: Rule.RuleContext): Rule.RuleListener { let importedFromReatom = false; return { ImportSpecifier(node) { const imported = node.imported.name; // @ts-ignore const from = node.parent.source.value; if (from.startsWith('@reatom') && imported === 'atom') { importedFromReatom = true; } }, VariableDeclarator: d => { if (!isAtomVariableDeclarator(d) || !importedFromReatom) return; if (d.init.arguments.length === 1) { reportUndefinedAtomName(context, d); } else if (isLiteral(d.init.arguments[1]) && d.init.arguments[1].value !== d.id.name) { reportBadAtomName(context, d); } } }; } } function isAtomCallExpression(node?: Node | null): node is AtomCallExpression { return node?.type === 'CallExpression' && node.callee?.type === 'Identifier' && node.callee.name === 'atom'; } function isAtomVariableDeclarator(node: VariableDeclarator): node is AtomVariableDeclarator { return isAtomCallExpression(node.init) && isIdentifier(node.id); } function reportUndefinedAtomName(context: Rule.RuleContext, d: AtomVariableDeclarator) { context.report({ message: noname(d.id.name), node: d, fix: fixer => fixer.insertTextAfter(d.init.arguments[0], `, "${d.id.name}"`) }); } function reportBadAtomName(context: Rule.RuleContext, d: AtomVariableDeclarator) { context.report({ message: invalidName(d.id.name), node: d, fix: fixer => fixer.replaceText(d.init.arguments[1], `"${d.id.name}"`) }); } As you can see, it just has better errors, type guards, and includes import checking. And, of course, I make the rule fixable. Updating the Docs To update the documents, you can use the following command: npm run update This command will update README.md and update docs for each rule (but you need to write a bit about each rule in the docs/{rule} file). Also, as I said, you don’t need to worry about the index file. Publish Step Ensure the version is in your package.json. "version": "1.0.0" Write in term if it’s not 1.0.0. npm version 1.0.0 Then just write in the root. npm publish Everything will be built and published with your defined package name. Connect It With Your Application I name my package. @reatom/eslint-plugin So, I need to install it. npm i @reatom/eslint-plugin And add to my .eslintrc config. module.exports = { plugins: [ "@reatom" ], // use all rules extends: [ "plugin:@reatom/recommended" ], // or pick some rules: { '@reatom/atom-rule': 'error', // aditional rules, you can see it in PR '@reatom/action-rule': 'error', '@reatom/reatom-prefix-rule': 'error' } } And everything just works (for just you should write instead everywhere). reatom-eslint-plugin “reatom” “@reatom" Conclusion In this tutorial, we walked through the process of creating an ESLint plugin for the Reatom state management library. We cover: How to write an eslint plugin in Typescript. How to cover it with tests. How to make it work with the --fix option. How to use my template. How to publish the eslint plugin. How to add it to your existing repository with eslint Resources for further learning and exploration https://github.com/pivaszbs/typescript-template-eslint-plugin https://astexplorer.net/ https://github.com/artalar/reatom/pull/488/files https://eslint.org/docs/latest/extend/plugins https://www.reatom.dev/ https://github.com/artalar/reatom https://docs.npmjs.com/about-semantic-versioning Have fun :)