Building a Custom ESLint Plugin: From AST Traversal to Auto-Fix
No built-in ESLint rule enforces your team-specific convention. Here is how to write a custom plugin with AST visitor setup, RuleTester tests, and auto-fix, using the ESLint 9 flat config format.
Your team has a convention that no built-in ESLint rule covers. Maybe it is “always import from @company/api instead of axios directly.” Maybe it is “logger calls must include the module name as the first argument.” Maybe it is “all React Server Components must use the use client directive.” Whatever the rule, the response is always the same: someone writes it in the PR review, someone else fixes it, and three months later a new hire introduces it again because nobody read the contributing guide.
The fix is a custom ESLint plugin. This post walks through the entire process: scaffolding the plugin, writing a rule with an auto-fix, testing it with RuleTester, and publishing it so your team can install it with npm install. I will use the ESLint 9 flat config format throughout, because the legacy .eslintrc format is being phased out.
The problem, concretely
Here is the rule we will build together as the running example.
The convention: All HTTP calls must go through a centralized @company/http module, not directly to axios, fetch, or node-fetch. Direct usage creates scattered timeout configs, inconsistent error handling, and a nightmare when the base URL changes.
The rule: @company/restrict-http-client flags any import from axios, any call to the global fetch(), and any import from node-fetch. It should suggest an auto-fix that replaces the banned import with the equivalent @company/http import. If it cannot suggest a meaningful replacement, it should at least point the developer to the right module.
This is not a rule that belongs in eslint-plugin-import or eslint-plugin-security. It is specific to your codebase. That is exactly the kind of rule you should write yourself.
What you need: ESLint plugin internals (3 concepts)
Before writing code, understand three things about how ESLint rules work under the hood.
1. Rules return a create method. The create method receives a context object with helpers (context.report, context.options, context.filename) and returns an object whose keys are AST node types. ESLint traverses the parsed AST and calls your visitor functions every time it encounters a matching node type.
2. You walk the AST, not the source text. When a developer writes import axios from 'axios', ESLint parses it into an ImportDeclaration AST node. Your rule inspects that node’s properties (source.value, specifiers) to decide whether to report. You never regex-match source code. You match structured syntax.
3. Every report can carry a fix. The context.report() call accepts a fix function that returns a Fix object describing the edit. ESLint applies the fix automatically when the user runs eslint --fix. No manual editing required.
Scaffolding the plugin
ESLint 9 uses the flat config system. Plugins are just objects with a rules map. You can publish them as npm packages or define them inline in your eslint.config.js.
Start with a minimal project structure:
@company/eslint-plugin-restrict-http-client/
package.json
src/
rules/
restrict-http-client.js
index.js
tests/
restrict-http-client.test.js
package.json:
{
"name": "@company/eslint-plugin-restrict-http-client",
"version": "1.0.0",
"type": "module",
"main": "src/index.js",
"exports": {
".": "./src/index.js"
},
"peerDependencies": {
"eslint": ">=9.0.0"
}
}
src/index.js: The plugin entry point exports the plugin object:
import restrictHttpClient from './rules/restrict-http-client.js';
const plugin = {
rules: {
'restrict-http-client': restrictHttpClient,
},
};
export default plugin;
That is the entire plugin shell. ESLint expects the rules key where each value is a rule module. The rule name prefix (@company/restrict-http-client) will be the full name when configured in eslint.config.js.
Writing the rule: reporting and the AST
Here is the rule module. I will explain each piece after the code.
/** @type {import('eslint').Rule.RuleModule} */
const rule = {
meta: {
type: 'suggestion',
docs: {
description: 'Enforce HTTP calls through @company/http instead of direct libraries',
},
fixable: 'code',
schema: [
{
type: 'object',
properties: {
bannedImports: {
type: 'array',
items: { type: 'string' },
},
bannedGlobalApis: {
type: 'array',
items: { type: 'string' },
},
},
additionalProperties: false,
},
],
messages: {
bannedImport: "Use '@company/http' instead of '{{source}}'. Direct HTTP clients bypass centralized timeout and error handling.",
bannedGlobal: "Use '@company/http' instead of the global '{{name}}()' call. Direct HTTP calls bypass centralized timeout and error handling.",
},
},
create(context) {
const options = context.options[0] || {};
const bannedImports = options.bannedImports ?? ['axios', 'node-fetch', 'got'];
const bannedGlobalApis = options.bannedGlobalApis ?? ['fetch'];
return {
ImportDeclaration(node) {
if (bannedImports.includes(node.source.value)) {
context.report({
node,
messageId: 'bannedImport',
data: { source: node.source.value },
fix(fixer) {
return buildFix(fixer, node, node.source.value);
},
});
}
},
CallExpression(node) {
if (
node.callee.type === 'Identifier' &&
bannedGlobalApis.includes(node.callee.name)
) {
// Only flag bare global calls like fetch(url), not window.fetch(url)
context.report({
node,
messageId: 'bannedGlobal',
data: { name: node.callee.name },
});
}
},
};
},
};
function buildFix(fixer, node, originalSource) {
if (originalSource === 'axios') {
const defaultSpecifier = node.specifiers.find(
(s) => s.type === 'ImportDefaultSpecifier'
);
if (defaultSpecifier) {
return fixer.replaceText(
node,
`import { http as ${defaultSpecifier.local.name} } from '@company/http';`
);
}
}
if (originalSource === 'node-fetch') {
const defaultSpecifier = node.specifiers.find(
(s) => s.type === 'ImportDefaultSpecifier'
);
if (defaultSpecifier) {
return fixer.replaceText(
node,
`import { http } from '@company/http';\n` +
`const ${defaultSpecifier.local.name} = http.request;`
);
}
}
// No specific fix available, just flag it
return null;
}
export default rule;
Let me walk through what is happening here.
meta.type: 'suggestion' tells ESLint this rule suggests a better practice. Other options are 'problem' (likely a bug) and 'layout' (formatting).
meta.fixable: 'code' declares that this rule provides a fix function. Without this, ESLint ignores your fix.
meta.schema: Defines the options the user can pass. This schema validates that user-provided options match the expected shape. ESLint’s context.options[0] will be the first argument from the config.
meta.messages: A map of message templates. Using messageId instead of a raw string in context.report is the recommended approach. You get automatic deduplication in ESLint output, and the messages are easier to maintain.
The ImportDeclaration visitor: Every time ESLint encounters an import ... from '...' statement, it calls this function with the AST node. We check if the imported source is in the banned list. If it is, we call context.report with a fix function.
The CallExpression visitor: Matches any function call. We check if the callee is a bare identifier (like fetch(url), not window.fetch(url)) and if it is in the banned global list. Note that we do not provide a fix for global calls because there is no clear structural replacement.
The buildFix function: Returns either a fix object or null. A fix object uses fixer.replaceText(node, newText) to replace the entire import statement. For axios, we map the default import to a named import from @company/http. For node-fetch, we generate a compatibility assignment. For got, we return null because there is no safe mechanical rewrite.
Testing the rule with RuleTester
ESLint ships with RuleTester, a utility that runs your rule against sample code and asserts the expected number of errors and the resulting fixed output. Do not test rules by linting a real file. Use RuleTester in every test file.
import { RuleTester } from 'eslint';
import rule from '../src/rules/restrict-http-client.js';
const tester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
});
tester.run('restrict-http-client', rule, {
valid: [
{
code: `import { http } from '@company/http';`,
},
{
code: `import { request } from '@company/http';`,
},
{
code: `const url = 'https://api.example.com';`,
},
{
code: `const result = await http.get('/users');`,
},
],
invalid: [
{
code: `import axios from 'axios';`,
errors: [{ messageId: 'bannedImport' }],
output: `import { http as axios } from '@company/http';`,
},
{
code: `import fetch from 'node-fetch';`,
errors: [{ messageId: 'bannedImport' }],
output:
`import { http } from '@company/http';\n` +
`const fetch = http.request;`,
},
{
code: `import got from 'got';`,
errors: [{ messageId: 'bannedImport' }],
output: `import got from 'got';`, // no fix for `got`
},
{
code: `const data = await fetch('/api/users');`,
errors: [{ messageId: 'bannedGlobal' }],
},
],
});
Run the tests:
node --test tests/restrict-http-client.test.js
RuleTester checks every assertion: that the specified number of errors are reported with the correct messageId, that the output matches if a fix is applied, and that valid cases produce zero errors. If a fix is provided but the output does not match, the test fails with a helpful diff.
Note the third invalid case: import got from 'got' should produce an error but NOT change the output. That tells RuleTester we expect the error without a fix. The output must equal the original code when no fix is applied. This is easy to forget and causes test failures.
Configuring the plugin in eslint.config.js
Flat config makes plugin registration straightforward.
import companyPlugin from '@company/eslint-plugin-restrict-http-client';
export default [
{
plugins: {
'@company': companyPlugin,
},
rules: {
'@company/restrict-http-client': [
'error',
{
bannedImports: ['axios', 'node-fetch', 'got', 'superagent'],
bannedGlobalApis: ['fetch'],
},
],
},
},
];
The '@company' key in plugins registers the plugin object. ESLint maps any rule named @company/restrict-http-client to companyPlugin.rules['restrict-http-client']. The array syntax ['error', options] sets the severity and passes the options object to context.options[0].
If you prefer an inline plugin (no npm package), create the rule object directly in your config file:
import restrictHttpClient from './eslint-rules/restrict-http-client.js';
export default [
{
plugins: {
local: {
rules: { 'restrict-http-client': restrictHttpClient },
},
},
rules: {
'local/restrict-http-client': 'error',
},
},
];
Publishing the plugin
Publishing a scoped package requires an npm org. Create an org matching the scope (@company) and publish:
cd @company/eslint-plugin-restrict-http-client
npm publish
Then your team installs it:
npm install --save-dev @company/eslint-plugin-restrict-http-client
The package name convention for ESLint plugins is eslint-plugin-<name>. ESLint resolves plugins: { '@company': ... } by looking for the @company/eslint-plugin-<name> package. If you name your package @company/foo, your ESLint plugin key should be @company/foo and rules would be @company/foo/rule-name. The more conventional approach is @company/eslint-plugin-restrict-http-client with plugin key @company and rule name restrict-http-client.
Beyond the example: AST patterns worth learning
The example above covers imports and call expressions. Most real-world rules need to inspect additional node types. Here are patterns I have used in production plugins.
Checking JSDoc on exported functions
create(context) {
return {
'FunctionDeclaration, ArrowFunctionExpression, FunctionExpression': (node) => {
const comments = context.sourceCode.getCommentsBefore(node);
const hasJsdoc = comments.some(
(c) => c.type === 'Block' && c.value.startsWith('*')
);
if (hasJsdoc) return;
// Check if this function is exported
const parent = node.parent;
const isExported = parent.type === 'ExportNamedDeclaration';
if (isExported) {
context.report({
node,
message: 'Exported functions must have a JSDoc comment.',
});
}
},
};
},
context.sourceCode.getCommentsBefore(node) returns all comment nodes that appear immediately before the given AST node. This replaced the legacy getComments() method in ESLint 9.
Enforcing naming conventions for identifiers
create(context) {
return {
Identifier(node) {
// Check if this is a variable declaration or parameter
if (
(node.parent.type === 'VariableDeclarator' && node.parent.id === node) ||
node.parent.type === 'FunctionDeclaration' ||
node.parent.type === 'FunctionExpression'
) {
// Exclude single-letter loop variables
if (node.name.length <= 2) return;
// Enforce camelCase
if (!/^[a-z][a-zA-Z0-9]*$/.test(node.name)) {
context.report({
node,
message: `Identifier '{{name}}' should be in camelCase.`,
data: { name: node.name },
});
}
}
},
};
},
The node.parent chain gives you access to the enclosing syntax structure. Every AST node except the root has a parent property set by ESLint during traversal.
Checking object property patterns
Suppose your API design convention requires that every route handler has a specific return shape:
create(context) {
return {
Property(node) {
if (
node.key.name === 'statusCode' &&
node.value.type === 'Literal' &&
node.value.value < 100
) {
context.report({
node,
message: 'statusCode must be >= 100.',
});
}
},
};
},
Using AST selectors for performance
ESLint supports CSS-style AST selectors. Instead of visiting every CallExpression and checking the callee, use a targeted selector that matches only the nodes you care about:
create(context) {
return {
'CallExpression[callee.name="fetch"]': (node) => {
context.report({
node,
message: "Use '@company/http' instead of global fetch().",
});
},
};
}
This is significantly faster than visiting every CallExpression and filtering inside the visitor, because ESLint skips all non-matching nodes at the traversal level. For rules that match only a handful of nodes across thousands of files, the selector syntax is the right choice.
Performance: making rules fast
A slow custom rule penalizes every developer on the team. Here are the most common performance traps.
Do not re-parse source text. Access AST properties through the node object. Do not extract the source text and run a regex when you can inspect node.source.value directly.
Do not use getSource() on every node. The legacy context.getSource(node) method serializes the node back to text. If you need to inspect the raw text of a node, use context.sourceCode.getText(node) sparingly, and prefer structural checks on the AST node properties.
Use selectors for sparsely matching rules. As shown above, 'CallExpression[callee.name="fetch"]' lets ESLint skip unrelated nodes entirely. A rule that visits every node and filters with an if statement runs its check on every expression in every file. A selector-based rule only fires when the pattern matches.
The for-real practical takeaway
Here is the decision tree for when to write a custom rule instead of relying on a code review or a contributing guide.
Write a rule if: the convention is mechanical (import from X not Y, call function with these arguments, use this naming pattern), the violation is detectable from a single file’s AST (you do not need cross-file analysis), and the fix is deterministic.
Do not write a rule if: the convention requires type information (use TypeScript for that), the rule needs data from another file (ESLint has limited cross-file analysis), or the convention is temporary and will be gone in two weeks.
For the first category, a custom plugin converts a tribal knowledge problem into an automated enforcement problem. That is a strict improvement. The time cost is one afternoon to scaffold, write, test, and publish the plugin. The return on that investment is zero hours spent explaining the convention in PRs from that point forward.
A note from Yojji
Custom tooling like an ESLint plugin converts team conventions from tribal knowledge that every new hire has to absorb into automated enforcement that fires on every PR. That is real engineering leverage, and it is the same principle Yojji applies when building development platforms for clients: invest once in automation, save the team from repeating the same judgment call a thousand times. Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their senior engineering teams specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and full-cycle product delivery from discovery through DevOps and production operations.