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:
javascript
export default {
meta: {
type: "problem", // "problem" | "suggestion" | "layout"
docs: {
description: "Disallow foo assigned to anything other than bar",
recommended: true,
url: "https://example.com/rules/no-foo",
},
fixable: "code", // "code" | "whitespace" | null
hasSuggestions: true,
schema: [], // JSON Schema for rule options
messages: {
avoidFoo: "Avoid using 'foo' - use 'bar' instead.",
suggestBar: "Replace with 'bar'.",
},
},
create(context) {
return {
// Visitor methods for AST nodes
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
javascript
create(context) {
// Rule configuration
context.id // Rule ID (e.g., "no-console")
context.options // Array of configured options
context.settings // Shared settings from config
// File information
context.filename // Current file path
context.cwd // Current working directory
// Source code access
context.sourceCode // SourceCode object for analysis
// Language configuration
context.languageOptions // Parser options, globals, etc.
}
Methods
javascript
// Report a problem
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:
javascript
create(context) {
return {
// Called when entering a node
CallExpression(node) {
// Analyze call expressions
},
// Called when exiting a node (use ":exit" suffix)
"FunctionDeclaration:exit"(node) {
// Run after all children processed
},
// Selector syntax for complex matching
"CallExpression[callee.name='require']"(node) {
// Only matches require() calls
},
};
}
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:
javascript
// Basic selectors
"Identifier"; // Any identifier
"CallExpression"; // Any function call
// Attribute selectors
"Identifier[name='foo']"; // Identifier named "foo"
"Literal[value=123]"; // Literal with value 123
"CallExpression[callee.name='require']"; // require() calls
// Descendant selectors
"FunctionDeclaration Identifier"; // Identifiers inside functions
// Child selectors
"CallExpression > MemberExpression"; // Direct child
// Sibling selectors
"VariableDeclaration ~ VariableDeclaration"; // Following sibling
// Pseudo-classes
":first-child"; // First child node
":last-child"; // Last child node
":nth-child(2)"; // Second child
":not(Literal)"; // Not a Literal
// Combinations
"CallExpression[callee.object.name='console'][callee.property.name='log']";
Reporting Problems
Basic Report
javascript
context.report({
node: node,
messageId: "unexpectedFoo",
data: { name: node.name },
});
Report with Location
javascript
context.report({
loc: {
start: { line: 1, column: 0 },
end: { line: 1, column: 5 },
},
messageId: "unexpectedFoo",
});
Report with Fix
javascript
context.report({
node,
messageId: "useBar",
fix(fixer) {
return fixer.replaceText(node, "bar");
},
});
Report with Suggestions
javascript
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:
javascript
// Insert text
fixer.insertTextBefore(node, "text");
fixer.insertTextAfter(node, "text");
fixer.insertTextBeforeRange([start, end], "text");
fixer.insertTextAfterRange([start, end], "text");
// Remove
fixer.remove(node);
fixer.removeRange([start, end]);
// Replace
fixer.replaceText(node, "newText");
fixer.replaceTextRange([start, end], "newText");
Multiple Fixes
Return an array or iterable for multiple fixes:
javascript
fix(fixer) {
return [
fixer.insertTextBefore(node, "/* comment */ "),
fixer.replaceText(node.property, "info"),
];
}
Accessing Source Code
javascript
create(context) {
const sourceCode = context.sourceCode;
return {
CallExpression(node) {
// Get source text
const text = sourceCode.getText(node);
// Get tokens
const tokens = sourceCode.getTokens(node);
const firstToken = sourceCode.getFirstToken(node);
const lastToken = sourceCode.getLastToken(node);
// Get comments
const commentsBefore = sourceCode.getCommentsBefore(node);
const commentsAfter = sourceCode.getCommentsAfter(node);
const commentsInside = sourceCode.getCommentsInside(node);
// Get scope information
const scope = sourceCode.getScope(node);
const variables = sourceCode.getDeclaredVariables(node);
}
};
}
Scope Analysis
Access variable scopes for advanced analysis:
javascript
create(context) {
return {
"Program:exit"(node) {
const scope = context.sourceCode.getScope(node);
// All variables in scope
scope.variables.forEach(variable => {
console.log(variable.name);
console.log(variable.references); // Where it's used
console.log(variable.defs); // Where it's defined
});
// Unresolved references (global access)
scope.through.forEach(reference => {
console.log(reference.identifier.name);
});
// Child scopes
scope.childScopes.forEach(childScope => {
console.log(childScope.type); // "function", "block", etc.
});
}
};
}
Rule Options
Define options using JSON Schema:
javascript
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 {
/* visitors */
};
},
};
Testing with RuleTester
Basic Test Setup
javascript
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: [
// Code that should pass
"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';", // Expected after fix
errors: [{ messageId: "unexpectedFoo" }],
},
],
});
Test Case Properties
javascript
{
code: "const foo = 123;", // Code to lint
output: "const foo = 'bar';", // Expected output after fix
options: [{ allowFoo: false }], // Rule options
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", // Virtual filename
only: true, // Run only this test
}
TypeScript-ESLint Testing
javascript
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
javascript
// eslint-plugin-myplugin/index.js
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
javascript
// eslint.config.js
import myplugin from "eslint-plugin-myplugin";
export default [
{
plugins: { myplugin },
rules: {
"myplugin/no-foo": "error",
},
},
// Or use the recommended config
myplugin.configs.recommended,
];
Local Rules (Without Publishing)
Using eslint-plugin-local-rules
javascript
// eslint-local-rules/no-foo.js
export default {
meta: { /* ... */ },
create(context) { /* ... */ }
};
// eslint-local-rules/index.js
import noFoo from "./no-foo.js";
export default {
rules: { "no-foo": noFoo }
};
javascript
// eslint.config.js
import localRules from "./eslint-local-rules/index.js";
export default [
{
plugins: { local: localRules },
rules: {
"local/no-foo": "error",
},
},
];
TypeScript Rules
Using @typescript-eslint/utils
typescript
import { ESLintUtils } from "@typescript-eslint/utils";