---
Write custom ESLint rules and plugins with AST-based logic, auto-fixes, and suggestions. Includes testing with RuleTester and TypeScript integration.
/plugin marketplace add shepherdjerred/monorepo/plugin install jerred@shepherdjerredeslint.config.js replaces .eslintrc.*defineConfig() helper: Type-safe configuration with autocompleteEvery ESLint rule exports an object with meta and create:
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",
});
}
}
};
}
};
| 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 passed to create() provides:
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.
}
// Report a problem
context.report({
node,
messageId: "myMessage",
data: { name: "foo" },
fix: (fixer) => fixer.replaceText(node, "bar"),
});
Rules work by defining visitor functions for AST node types:
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
},
};
}
| 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 |
ESLint supports CSS-like selectors for targeting nodes:
// 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']"
context.report({
node: node,
messageId: "unexpectedFoo",
data: { name: node.name },
});
context.report({
loc: {
start: { line: 1, column: 0 },
end: { line: 1, column: 5 }
},
messageId: "unexpectedFoo"
});
context.report({
node,
messageId: "useBar",
fix(fixer) {
return fixer.replaceText(node, "bar");
}
});
context.report({
node,
messageId: "useBetterName",
suggest: [
{
messageId: "renameToBar",
fix(fixer) {
return fixer.replaceText(node, "bar");
}
},
{
messageId: "renameToQux",
fix(fixer) {
return fixer.replaceText(node, "qux");
}
}
]
});
The fixer object provides these methods:
// 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")
Return an array or iterable for multiple fixes:
fix(fixer) {
return [
fixer.insertTextBefore(node, "/* comment */ "),
fixer.replaceText(node.property, "info"),
];
}
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);
}
};
}
Access variable scopes for advanced analysis:
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.
});
}
};
}
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 { /* visitors */ };
}
};
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" }],
},
],
});
{
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
}
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",
},
});
// 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 };
// 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,
];
// 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 }
};
// eslint.config.js
import localRules from "./eslint-local-rules/index.js";
export default [
{
plugins: { local: localRules },
rules: {
"local/no-foo": "error",
},
},
];
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" });
},
};
},
});
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" });
}
},
};
}
messageId instead of inline strings for messagesschema for any rule optionsmeta.fixable if providing auto-fixesYou are an elite AI agent architect specializing in crafting high-performance agent configurations. Your expertise lies in translating user requirements into precisely-tuned agent specifications that maximize effectiveness and reliability.