ESLint Plugin Author
Write custom ESLint rules using TDD. This skill covers rule creation, testing, and plugin packaging.
When to Use
-
Enforcing project-specific coding standards
-
Creating rules with auto-fix or suggestions
-
Building TypeScript-aware rules using type information
-
Migrating from deprecated rules
Workflow
Copy and track:
ESLint Rule Progress:
- Clarify transformation (before/after examples)
- Ask edge case questions (see below)
- Detect project setup (config format, test runner)
- Write failing tests first
- Implement rule to pass tests
- Add edge case tests
- Document the rule
Edge Case Discovery
CRITICAL: Ask these BEFORE writing code.
Always Ask
-
Should the rule apply to all file types or specific extensions?
-
Should it be auto-fixable, provide suggestions, or just report?
-
Are any patterns exempt (test files, generated code)?
By Rule Type
Type Key Questions
Identifiers Variables, functions, classes, or all? Destructured? Renamed imports?
Imports Re-exports? Dynamic imports? Type-only? Side-effect imports?
Functions Arrow vs declaration? Methods vs standalone? Async? Generators?
JSX JSX and createElement? Fragments? Self-closing? Spread props?
TypeScript Require type info? Handle any ? Generics? Type assertions?
Project Setup Detection
Config Format
Files Present Format
eslint.config.js/mjs/cjs/ts
Flat config (ESLint 9+)
.eslintrc.* or eslintConfig in package.json Legacy
Test Runner
Check package.json devDependencies:
-
Bun: bun:test or bun
-
Vitest: vitest
-
Jest: jest
Rule Template
// src/rules/rule-name.ts import { ESLintUtils } from "@typescript-eslint/utils";
const createRule = ESLintUtils.RuleCreator(
(name) => https://example.com/rules/${name}
);
type Options = [{ optionName?: boolean }]; type MessageIds = "errorId" | "suggestionId";
export default createRule<Options, MessageIds>({ name: "rule-name", meta: { type: "problem", // "problem" | "suggestion" | "layout" docs: { description: "What this rule does" }, fixable: "code", // Only if auto-fixable hasSuggestions: true, // Only if has suggestions messages: { errorId: "Error: {{ placeholder }}", suggestionId: "Try this instead", }, schema: [{ type: "object", properties: { optionName: { type: "boolean" } }, additionalProperties: false, }], }, defaultOptions: [{ optionName: false }],
create(context, [options]) { return { // Use AST selectors - see references/code-patterns.md "CallExpression[callee.name='forbidden']"(node) { context.report({ node, messageId: "errorId", fix(fixer) { return fixer.replaceText(node, "replacement"); }, }); }, }; }, });
Test Template
// src/rules/tests/rule-name.test.ts import { afterAll, describe, it } from "bun:test"; // or vitest import { RuleTester } from "@typescript-eslint/rule-tester"; import rule from "../rule-name";
// Configure BEFORE creating instance RuleTester.afterAll = afterAll; RuleTester.describe = describe; RuleTester.it = it; RuleTester.itOnly = it.only;
const ruleTester = new RuleTester({ languageOptions: { parserOptions: { ecmaVersion: "latest", sourceType: "module", }, }, });
ruleTester.run("rule-name", rule, {
valid: [
const allowed = 1;,
{
code: const exempt = 1;,
name: "ignores exempt pattern",
},
],
invalid: [
{
code: const bad = 1;,
output: const good = 1;,
errors: [{ messageId: "errorId" }],
name: "fixes main case",
},
],
});
For other test runners and patterns, see references/test-patterns.md.
Type-Aware Rules
For rules needing TypeScript type information:
import { ESLintUtils } from "@typescript-eslint/utils";
create(context) { const services = ESLintUtils.getParserServices(context);
return { CallExpression(node) { // v6+ simplified API - direct call const type = services.getTypeAtLocation(node);
if (type.symbol?.flags & ts.SymbolFlags.Enum) {
context.report({ node, messageId: "enumError" });
}
},
}; }
Test config for type-aware rules:
import parser from "@typescript-eslint/parser";
const ruleTester = new RuleTester({ languageOptions: { parser, parserOptions: { projectService: { allowDefaultProject: [".ts"] }, tsconfigRootDir: import.meta.dirname, }, }, });
Plugin Structure (Flat Config)
// src/index.ts import { defineConfig } from "eslint/config"; import rule1 from "./rules/rule1";
const plugin = { meta: { name: "eslint-plugin-my-plugin", version: "1.0.0" }, configs: {} as Record<string, unknown>, rules: { "rule1": rule1 }, };
Object.assign(plugin.configs, { recommended: defineConfig([{ plugins: { "my-plugin": plugin }, rules: { "my-plugin/rule1": "error" }, }]), });
export default plugin;
For legacy and dual-format plugins, see references/plugin-templates.md.
Required Test Coverage
Category Purpose
Main case Core transformation
No-op Unrelated code unchanged
Idempotency Already-fixed code stays fixed
Edge cases Variations from spec
Options Different configurations
Quick Reference
Rule Types
Type Use Case
problem
Code that causes errors
suggestion
Style improvements
layout
Whitespace/formatting
Fixer Methods
fixer.replaceText(node, "new") fixer.insertTextBefore(node, "prefix") fixer.insertTextAfter(node, "suffix") fixer.remove(node) fixer.replaceTextRange([start, end], "new")
Common Selectors
"CallExpression[callee.name='target']" // Function call by name "MemberExpression[property.name='prop']" // Property access "ImportDeclaration[source.value='pkg']" // Import from package "Identifier[name='forbidden']" // Identifier by name ":not(CallExpression)" // Negation "FunctionDeclaration:exit" // Exit visitor
References
-
Code Patterns - AST selectors, fixer API, reporting, context API
-
Test Patterns - RuleTester setup for Bun/Vitest/Jest, test cases
-
Plugin Templates - Flat config, legacy, dual-format structures
-
Troubleshooting - Common issues, debugging techniques
External Tools
-
AST Explorer: https://astexplorer.net (select @typescript-eslint/parser)
-
ast-grep: sg --lang ts -p 'pattern' for structural searches