| name | eslint-rule-dev |
| description | ESLint custom rule development - AST traversal, rule testing, plugins, and flat config
When user creates ESLint rules, develops ESLint plugins, works with AST, or mentions RuleTester
|
ESLint Rule Development Agent
What's New in ESLint 9+ (2024-2025)
- Flat config:
eslint.config.js replaces .eslintrc.*
- ESM support: Native ES modules in configs and rules
defineConfig() helper: Type-safe configuration with autocomplete
- Stricter plugin format: Plugins must use new object structure
- Removed formatters: Many built-in formatters moved to packages
Rule Structure
Every ESLint rule exports an object with meta and create:
export default {
meta: {
type: "problem",
docs: {
description: "Disallow foo assigned to anything other than bar",
recommended: true,
url: "https://example.com/rules/no-foo",
},
fixable: "code",
hasSuggestions: true,
schema: [],
messages: {
avoidFoo: "Avoid using 'foo' - use 'bar' instead.",
suggestBar: "Replace with 'bar'.",
},
},
create(context) {
return {
Identifier(node) {
if (node.name === "foo") {
context.report({
node,
messageId: "avoidFoo",
});
}
},
};
},
};
Meta Properties
| Property | Purpose |
|---|
type | Rule category: "problem", "suggestion", "layout" |
docs.description | Short description for documentation |
docs.recommended | Include in recommended config |
docs.url | Link to full documentation |
fixable | Enable auto-fix ("code" or "whitespace") |
hasSuggestions | Rule provides suggestions |
schema | JSON Schema for options validation |
messages | Message templates with IDs |
defaultOptions | Default values for options |
deprecated | Mark rule as deprecated |
The Context Object
The context object passed to create() provides:
Properties
create(context) {
context.id
context.options
context.settings
context.filename
context.cwd
context.sourceCode
context.languageOptions
}
Methods
context.report({
node,
messageId: "myMessage",
data: { name: "foo" },
fix: (fixer) => fixer.replaceText(node, "bar"),
});
AST Node Visitors
Rules work by defining visitor functions for AST node types:
create(context) {
return {
CallExpression(node) {
},
"FunctionDeclaration:exit"(node) {
},
"CallExpression[callee.name='require']"(node) {
},
};
}
Common Node Types
| Node Type | Matches |
|---|
Identifier | Variable names, function names |
Literal | Strings, numbers, booleans |
CallExpression | Function calls |
MemberExpression | Property access (a.b, a['b']) |
FunctionDeclaration | Named function declarations |
ArrowFunctionExpression | Arrow functions |
VariableDeclaration | let, const, var declarations |
ImportDeclaration | import statements |
ExportDefaultDeclaration | export default |
AST Selectors
ESLint supports CSS-like selectors for targeting nodes:
"Identifier";
"CallExpression";
"Identifier[name='foo']";
"Literal[value=123]";
"CallExpression[callee.name='require']";
"FunctionDeclaration Identifier";
"CallExpression > MemberExpression";
"VariableDeclaration ~ VariableDeclaration";
":first-child";
":last-child";
":nth-child(2)";
":not(Literal)";
"CallExpression[callee.object.name='console'][callee.property.name='log']";
Reporting Problems
Basic Report
context.report({
node: node,
messageId: "unexpectedFoo",
data: { name: node.name },
});
Report with Location
context.report({
loc: {
start: { line: 1, column: 0 },
end: { line: 1, column: 5 },
},
messageId: "unexpectedFoo",
});
Report with Fix
context.report({
node,
messageId: "useBar",
fix(fixer) {
return fixer.replaceText(node, "bar");
},
});
Report with Suggestions
context.report({
node,
messageId: "useBetterName",
suggest: [
{
messageId: "renameToBar",
fix(fixer) {
return fixer.replaceText(node, "bar");
},
},
{
messageId: "renameToQux",
fix(fixer) {
return fixer.replaceText(node, "qux");
},
},
],
});
Fixer Methods
The fixer object provides these methods:
fixer.insertTextBefore(node, "text");
fixer.insertTextAfter(node, "text");
fixer.insertTextBeforeRange([start, end], "text");
fixer.insertTextAfterRange([start, end], "text");
fixer.remove(node);
fixer.removeRange([start, end]);
fixer.replaceText(node, "newText");
fixer.replaceTextRange([start, end], "newText");
Multiple Fixes
Return an array or iterable for multiple fixes:
fix(fixer) {
return [
fixer.insertTextBefore(node, "/* comment */ "),
fixer.replaceText(node.property, "info"),
];
}
Accessing Source Code
create(context) {
const sourceCode = context.sourceCode;
return {
CallExpression(node) {
const text = sourceCode.getText(node);
const tokens = sourceCode.getTokens(node);
const firstToken = sourceCode.getFirstToken(node);
const lastToken = sourceCode.getLastToken(node);
const commentsBefore = sourceCode.getCommentsBefore(node);
const commentsAfter = sourceCode.getCommentsAfter(node);
const commentsInside = sourceCode.getCommentsInside(node);
const scope = sourceCode.getScope(node);
const variables = sourceCode.getDeclaredVariables(node);
}
};
}
Scope Analysis
Access variable scopes for advanced analysis:
create(context) {
return {
"Program:exit"(node) {
const scope = context.sourceCode.getScope(node);
scope.variables.forEach(variable => {
console.log(variable.name);
console.log(variable.references);
console.log(variable.defs);
});
scope.through.forEach(reference => {
console.log(reference.identifier.name);
});
scope.childScopes.forEach(childScope => {
console.log(childScope.type);
});
}
};
}
Rule Options
Define options using JSON Schema:
export default {
meta: {
schema: [
{
type: "object",
properties: {
allowFoo: { type: "boolean", default: false },
maxLength: { type: "integer", minimum: 1 },
},
additionalProperties: false,
},
],
defaultOptions: [{ allowFoo: false, maxLength: 10 }],
},
create(context) {
const options = context.options[0] || {};
const allowFoo = options.allowFoo ?? false;
const maxLength = options.maxLength ?? 10;
return {
};
},
};
Testing with RuleTester
Basic Test Setup
import { RuleTester } from "eslint";
import rule from "./my-rule.js";
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: "module",
},
});
ruleTester.run("my-rule", rule, {
valid: [
"const bar = 'hello';",
{ code: "const foo = 'bar';", options: [{ allowFoo: true }] },
],
invalid: [
{
code: "const foo = 'hello';",
errors: [{ messageId: "unexpectedFoo" }],
},
{
code: "const foo = 'hello';",
output: "const foo = 'bar';",
errors: [{ messageId: "unexpectedFoo" }],
},
],
});
Test Case Properties
{
code: "const foo = 123;",
output: "const foo = 'bar';",
options: [{ allowFoo: false }],
errors: [
{
messageId: "unexpectedFoo",
data: { name: "foo" },
type: "VariableDeclarator",
line: 1,
column: 7,
endLine: 1,
endColumn: 10,
suggestions: [
{
messageId: "renameToBar",
output: "const bar = 123;",
}
]
}
],
filename: "test.js",
only: true,
}
TypeScript-ESLint Testing
import { RuleTester } from "@typescript-eslint/rule-tester";
import rule from "./my-ts-rule";
const ruleTester = new RuleTester({
parser: "@typescript-eslint/parser",
parserOptions: {
project: "./tsconfig.json",
},
});
Creating a Plugin
Plugin Structure
import noFoo from "./rules/no-foo.js";
import preferBar from "./rules/prefer-bar.js";
export default {
meta: {
name: "eslint-plugin-myplugin",
version: "1.0.0",
},
rules: {
"no-foo": noFoo,
"prefer-bar": preferBar,
},
configs: {
recommended: {
plugins: {
myplugin: plugin,
},
rules: {
"myplugin/no-foo": "error",
"myplugin/prefer-bar": "warn",
},
},
},
};
const plugin = { meta, rules, configs };
Using the Plugin
import myplugin from "eslint-plugin-myplugin";
export default [
{
plugins: { myplugin },
rules: {
"myplugin/no-foo": "error",
},
},
myplugin.configs.recommended,
];
Local Rules (Without Publishing)
Using eslint-plugin-local-rules
export default {
meta: { },
create(context) { }
};
import noFoo from "./no-foo.js";
export default {
rules: { "no-foo": noFoo }
};
import localRules from "./eslint-local-rules/index.js";
export default [
{
plugins: { local: localRules },
rules: {
"local/no-foo": "error",
},
},
];
TypeScript Rules
Using @typescript-eslint/utils
import { ESLintUtils } from "@typescript-eslint/utils";
const createRule = ESLintUtils.RuleCreator(
(name) => `https://example.com/rules/${name}`,
);
export default createRule({
name: "no-unsafe-any",
meta: {
type: "problem",
docs: { description: "Disallow unsafe any usage" },
messages: { unsafeAny: "Avoid using 'any' type" },
schema: [],
},
defaultOptions: [],
create(context) {
return {
TSAnyKeyword(node) {
context.report({ node, messageId: "unsafeAny" });
},
};
},
});
Accessing Type Information
import { ESLintUtils } from "@typescript-eslint/utils";
create(context) {
const services = ESLintUtils.getParserServices(context);
const checker = services.program.getTypeChecker();
return {
Identifier(node) {
const tsNode = services.esTreeNodeToTSNodeMap.get(node);
const type = checker.getTypeAtLocation(tsNode);
const typeString = checker.typeToString(type);
if (typeString === "any") {
context.report({ node, messageId: "foundAny" });
}
},
};
}
Best Practices Summary
- Use
messageId instead of inline strings for messages
- Define
schema for any rule options
- Set
meta.fixable if providing auto-fixes
- Make fixes minimal - only change what's necessary
- Test both valid and invalid cases with RuleTester
- Use AST Explorer (astexplorer.net) to understand node structure
- Handle edge cases - optional chaining, spread operators, etc.
- Provide suggestions when multiple valid fixes exist
- Check for comments before removing/replacing code
- Document thoroughly with examples and rationale
When to Ask for Help
- Complex scope analysis across multiple files
- Type-aware rules needing TypeScript integration
- Performance optimization for large codebases
- Migration from eslintrc to flat config
- Custom parsers for non-standard syntax
- Rule conflicts and fix ordering issues