eslint-plugin
Author custom ESLint plugins and rules with test-driven development. Supports flat config (eslint.config.js) and legacy (.eslintrc.*) formats. Uses @typescript-eslint/rule-tester for testing. Covers problem, suggestion, and layout rules including auto-fixers.
$ インストール
git clone https://github.com/third774/dotfiles /tmp/dotfiles && cp -r /tmp/dotfiles/opencode/skill/eslint-plugins ~/.claude/skills/dotfiles// tip: Run this command in your terminal to install the skill
name: eslint-plugin description: Author custom ESLint plugins and rules with test-driven development. Supports flat config (eslint.config.js) and legacy (.eslintrc.*) formats. Uses @typescript-eslint/rule-tester for testing. Covers problem, suggestion, and layout rules including auto-fixers.
ESLint Plugin Author
You are an expert at writing custom ESLint plugins and rules using test-driven development. This skill guides you through creating robust, well-tested rules that follow established patterns.
When to Use This Skill
- Enforcing project-specific coding standards
- Creating custom rules for domain-specific patterns
- Building rules with auto-fix capabilities
- Creating TypeScript-aware rules using type information
- Developing rules that provide suggestions for manual fixes
- Migrating from deprecated rules to custom implementations
Project Setup Detection
Before writing rules, detect the project's ESLint configuration format and testing infrastructure.
Config Format Detection
# Check for flat config (ESLint 9+)
# - eslint.config.js / eslint.config.mjs / eslint.config.cjs / eslint.config.ts
# Check for legacy config
# - .eslintrc.js / .eslintrc.cjs / .eslintrc.json
# - .eslintrc.yaml / .eslintrc.yml
# - "eslintConfig" in package.json
Test Framework Detection
Check package.json for test runner:
- Bun:
bun:testor bun in devDependencies (preferred) - Vitest: vitest in devDependencies
- Jest: jest in devDependencies
TypeScript Detection
Check for TypeScript setup:
tsconfig.jsonexists@typescript-eslint/parserin dependencies@typescript-eslint/eslint-pluginin dependencies
Edge Case Discovery
CRITICAL: Before writing ANY code or tests, you MUST ask clarifying questions about edge cases.
Standard Questions (Always Ask)
- "What should happen when the pattern appears inside comments? Should it be ignored?"
- "Should this rule apply to all file types, or only specific extensions (
.ts,.tsx,.js,.jsx)?" - "Should the rule be auto-fixable, provide suggestions, or just report errors?"
- "Are there any existing patterns that should be exempt (e.g., test files, generated code)?"
Rule-Type-Specific Questions
For Identifier/Naming Rules
- "Should the rule apply to variables, functions, classes, or all identifiers?"
- "What about destructured variables? Should
const { oldName } = objbe flagged?" - "How should renamed imports be handled?
import { thing as alias }" - "Should type-only imports/exports be included?"
For Import/Export Rules
- "How should re-exports be handled?
export { foo } from './bar'" - "Should dynamic imports be included?
import('./module')" - "What about type-only imports in TypeScript?
import type { Foo }" - "Should side-effect imports be considered?
import './styles.css'"
For Function/Method Rules
- "Should arrow functions be treated the same as function declarations?"
- "What about methods in classes vs. standalone functions?"
- "Should async functions be handled differently?"
- "How should generator functions be treated?"
For JSX/React Rules
- "Should the rule apply to both JSX elements and
React.createElementcalls?" - "What about fragments (
<>...</>vs<React.Fragment>)?" - "Should self-closing tags be treated differently?"
- "How should spread props be handled?
<Component {...props} />"
For TypeScript-Specific Rules
- "Should the rule only run when type information is available?"
- "How should
anytypes be handled - flag, ignore, or special case?" - "Should generic type parameters be considered?"
- "What about type assertions (
asor<Type>)?"
Edge Case Scenario Example
// Scenario: Rule that disallows console.log
// Edge cases to clarify:
// 1. What about console.warn, console.error, console.info?
console.warn("warning"); // Flag or allow?
// 2. What about computed property access?
const method = "log";
console[method]("indirect"); // Can we detect this?
// 3. What about destructuring?
const { log } = console;
log("destructured"); // Flag or allow?
// 4. What about reassignment?
const myLog = console.log;
myLog("aliased"); // Flag or allow?
// 5. What about in try-catch for debugging?
try {
} catch (e) {
console.log(e);
} // Exception?
Rule Creation Process (TDD)
Step 1: Understand the Rule Intent
Before proceeding, clarify:
- What pattern are we detecting? (Provide code examples)
- What should the error message say?
- What is the correct code? (Provide "after" examples)
- Rule category: Is this a problem (error), suggestion (improvement), or layout (formatting)?
Step 2: Identify Edge Cases (ASK QUESTIONS)
This step MUST involve asking the user clarifying questions.
Before I write any tests, I want to make sure we handle edge cases correctly.
Let me ask about a few scenarios:
1. [Edge case question 1]
2. [Edge case question 2]
3. [Edge case question 3]
Please let me know how each of these should be handled.
Step 3: Write Tests First
Always write tests before implementing the rule. Tests serve as:
- The specification for what the rule should do
- The primary documentation for human reviewers
- A safety net for iterating on the implementation
Test file structure with @typescript-eslint/rule-tester and Bun:
// Location: src/rules/__tests__/rule-name.test.ts
import { afterAll, describe, it } from "bun:test";
import { RuleTester } from "@typescript-eslint/rule-tester";
import rule from "../rule-name";
// Configure RuleTester for Bun BEFORE creating instance
RuleTester.afterAll = afterAll;
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;
const ruleTester = new RuleTester({
languageOptions: {
parser: require("@typescript-eslint/parser"),
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
ecmaFeatures: { jsx: true },
},
},
});
ruleTester.run("rule-name", rule, {
valid: [
// ─── VALID: Basic case ─────────────────────────────────────
`const x = 1;`,
// ─── VALID: Already correct pattern ────────────────────────
{
code: `/* already correct */`,
name: "ignores already-correct code",
},
],
invalid: [
// ─── INVALID: Main transformation ──────────────────────────
{
code: `/* problematic code */`,
output: `/* fixed code */`, // For auto-fixable rules
errors: [{ messageId: "errorId" }],
name: "detects and fixes the main case",
},
// ─── INVALID: Edge case with options ───────────────────────
{
code: `/* edge case */`,
options: [{ someOption: true }],
errors: [
{
messageId: "errorId",
line: 1,
column: 5,
},
],
name: "handles edge case with option",
},
],
});
Required test categories for every rule:
| Category | Purpose | Example |
|---|---|---|
| Main case | Core transformation | The primary before/after |
| No-op | Files without pattern unchanged | Unrelated code passes |
| Idempotency | Running twice = running once | Already-fixed code |
| Edge cases | Variations from Q&A | Destructuring, aliases, etc. |
| Options | Different configurations | With/without flags |
Step 4: Implement the Rule
Rule structure template:
// Location: src/rules/rule-name.ts
import { ESLintUtils, TSESTree } from "@typescript-eslint/utils";
// Create rule with documentation URL
const createRule = ESLintUtils.RuleCreator(
(name) => `https://example.com/rules/${name}`,
);
// Type for rule options
type Options = [
{
someOption?: boolean;
},
];
// Type for message IDs
type MessageIds = "errorMessageId" | "suggestionMessageId";
export default createRule<Options, MessageIds>({
name: "rule-name",
meta: {
type: "problem", // "problem" | "suggestion" | "layout"
docs: {
description: "Description of what the rule does",
},
fixable: "code", // Include only if auto-fixable
hasSuggestions: true, // Include only if has suggestions
messages: {
errorMessageId: "Error message with {{ placeholder }}",
suggestionMessageId: "Suggestion: do this instead",
},
schema: [
{
type: "object",
properties: {
someOption: { type: "boolean" },
},
additionalProperties: false,
},
],
},
defaultOptions: [{ someOption: false }],
create(context, [options]) {
return {
// Visitor methods using AST selectors
Identifier(node) {
// Rule logic
},
};
},
});
Step 5: Run Tests and Iterate
# Run tests with Bun
bun test src/rules/__tests__/rule-name.test.ts
# Watch mode during development
bun test --watch src/rules/__tests__/rule-name.test.ts
Step 6: Document the Rule
# rule-name
Description of what the rule does.
## Rule Details
This rule [enforces/disallows/suggests] ...
### Examples of **incorrect** code:
\`\`\`javascript
/_ eslint my-plugin/rule-name: "error" _/
// bad code example
\`\`\`
### Examples of **correct** code:
\`\`\`javascript
/_ eslint my-plugin/rule-name: "error" _/
// good code example
\`\`\`
## Options
- `someOption` (boolean, default: `false`) - Description
Code Patterns
Rule Meta Object Patterns
// Problem rule (code likely to cause errors)
meta: {
type: "problem",
docs: { description: "Disallow X because it causes Y" },
fixable: "code",
messages: { detected: "Found X, which causes Y" },
schema: [],
}
// Suggestion rule (code style improvement)
meta: {
type: "suggestion",
docs: { description: "Prefer X over Y for consistency" },
hasSuggestions: true,
messages: {
prefer: "Prefer {{ preferred }} over {{ actual }}",
useSuggestion: "Replace with {{ preferred }}",
},
schema: [],
}
// Layout rule (whitespace, formatting)
meta: {
type: "layout",
docs: { description: "Enforce consistent spacing" },
fixable: "whitespace",
messages: { spacing: "Expected {{ expected }} spaces" },
schema: [],
}
Context API Patterns
create(context) {
// Access options
const options = context.options[0] ?? {};
// Access source code
const sourceCode = context.sourceCode;
// Get text of a node
const text = sourceCode.getText(node);
// Get tokens
const token = sourceCode.getTokenBefore(node);
const nextToken = sourceCode.getTokenAfter(node);
// Get comments
const comments = sourceCode.getCommentsBefore(node);
// Get scope information
const scope = sourceCode.getScope(node);
// Get ancestor nodes
const ancestors = sourceCode.getAncestors(node);
return { /* visitors */ };
}
Reporting Patterns
// Basic report
context.report({
node,
messageId: "errorId",
});
// Report with data placeholders
context.report({
node,
messageId: "errorWithData",
data: {
name: node.name,
expected: "something",
},
});
// Report with specific location
context.report({
node,
loc: node.loc.start, // or { line, column }
messageId: "errorId",
});
// Report with fix
context.report({
node,
messageId: "fixable",
fix(fixer) {
return fixer.replaceText(node, "replacement");
},
});
// Report with multiple fixes
context.report({
node,
messageId: "multipleChanges",
fix(fixer) {
return [
fixer.insertTextBefore(node, "prefix"),
fixer.insertTextAfter(node, "suffix"),
];
},
});
// Report with suggestions
context.report({
node,
messageId: "hasSuggestions",
suggest: [
{
messageId: "suggestion1",
fix(fixer) {
return fixer.replaceText(node, "option1");
},
},
{
messageId: "suggestion2",
fix(fixer) {
return fixer.replaceText(node, "option2");
},
},
],
});
Fixer API Reference
fix(fixer) {
// 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");
}
AST Traversal Patterns
create(context) {
return {
// Simple node type
Identifier(node) { },
// Descending (default) vs ascending
"FunctionDeclaration:exit"(node) { },
// CSS selector syntax
"CallExpression[callee.name='forbidden']"(node) { },
// Multiple node types
"FunctionDeclaration, ArrowFunctionExpression"(node) { },
// Nested selectors
"CallExpression > Identifier[name='console']"(node) { },
// With attribute conditions
"Literal[value=true]"(node) { },
// Parent selector
"ImportDeclaration:has(ImportSpecifier[imported.name='deprecated'])"(node) { },
};
}
Type-Aware Rule Patterns
import { ESLintUtils } from "@typescript-eslint/utils";
create(context) {
// Get TypeScript services
const services = ESLintUtils.getParserServices(context);
const checker = services.program.getTypeChecker();
return {
CallExpression(node) {
// Get TypeScript node from ESTree node
const tsNode = services.esTreeNodeToTSNodeMap.get(node);
// Get type at location
const type = checker.getTypeAtLocation(tsNode);
// Check if type matches
if (checker.typeToString(type) === "Promise<void>") {
// Handle Promise return
}
// Get return type of function
const signatures = type.getCallSignatures();
if (signatures.length > 0) {
const returnType = signatures[0].getReturnType();
}
},
};
}
Test Patterns
Basic Test Structure
import { afterAll, describe, it } from "bun:test";
import { RuleTester } from "@typescript-eslint/rule-tester";
import rule from "../rule-name";
// Setup BEFORE creating RuleTester
RuleTester.afterAll = afterAll;
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;
const ruleTester = new RuleTester();
ruleTester.run("rule-name", rule, {
valid: [],
invalid: [],
});
Valid Test Cases
valid: [
// Simple string
`const x = 1;`,
// Object with name
{
code: `const x = 1;`,
name: "allows basic assignment",
},
// With options
{
code: `const x = 1;`,
options: [{ allowX: true }],
name: "allows X when option enabled",
},
// With JSX
{
code: `<Component />`,
languageOptions: {
parserOptions: {
ecmaFeatures: { jsx: true },
},
},
name: "handles JSX",
},
// Different filename
{
code: `console.log('test');`,
filename: "test.spec.ts",
name: "allows in test files",
},
],
Invalid Test Cases
invalid: [
// Basic error
{
code: `const bad = 1;`,
errors: [{ messageId: "forbidden" }],
name: "detects forbidden pattern",
},
// With fix output
{
code: `const bad = 1;`,
output: `const good = 1;`,
errors: [{ messageId: "forbidden" }],
name: "fixes forbidden pattern",
},
// With specific error location
{
code: `const bad = 1;`,
errors: [
{
messageId: "forbidden",
line: 1,
column: 7,
endLine: 1,
endColumn: 10,
},
],
name: "reports correct location",
},
// With data validation
{
code: `const bad = 1;`,
errors: [
{
messageId: "forbidden",
data: { name: "bad" },
},
],
name: "includes correct data",
},
// Multiple errors
{
code: `const bad1 = 1; const bad2 = 2;`,
errors: [
{ messageId: "forbidden" },
{ messageId: "forbidden" },
],
name: "detects multiple violations",
},
// No fix expected
{
code: `const complexBad = 1;`,
output: null, // Assert no fix
errors: [{ messageId: "tooComplex" }],
name: "does not fix complex cases",
},
],
Testing Suggestions
{
code: `const problematic = 1;`,
errors: [
{
messageId: "hasOptions",
suggestions: [
{
messageId: "option1",
output: `const fixed1 = 1;`,
},
{
messageId: "option2",
output: `const fixed2 = 1;`,
},
],
},
],
name: "provides multiple suggestions",
},
// Assert NO suggestions
{
code: `const edge = 1;`,
errors: [
{
messageId: "noSuggestions",
suggestions: null,
},
],
name: "does not suggest for edge case",
},
Type-Aware Rule Testing
const ruleTester = new RuleTester({
languageOptions: {
parser: require("@typescript-eslint/parser"),
parserOptions: {
projectService: {
allowDefaultProject: ["*.ts*"],
},
tsconfigRootDir: __dirname,
},
},
});
ruleTester.run("type-aware-rule", rule, {
valid: [
{
code: `
async function foo(): Promise<void> {
await someAsyncOp();
}
`,
name: "allows awaited promises",
},
],
invalid: [
{
code: `
async function foo(): Promise<void> {
someAsyncOp(); // Not awaited
}
`,
errors: [{ messageId: "floatingPromise" }],
name: "detects floating promises",
},
],
});
Plugin Structure
Flat Config Plugin (eslint.config.js)
// src/index.ts
import rule1 from "./rules/rule1";
import rule2 from "./rules/rule2";
const plugin = {
meta: {
name: "eslint-plugin-my-plugin",
version: "1.0.0",
},
configs: {} as Record<string, unknown>,
rules: {
rule1: rule1,
rule2: rule2,
},
};
// Self-referential configs (must be after plugin definition)
Object.assign(plugin.configs, {
recommended: {
plugins: {
"my-plugin": plugin,
},
rules: {
"my-plugin/rule1": "error",
"my-plugin/rule2": "warn",
},
},
});
export default plugin;
Usage in flat config:
// eslint.config.js
import myPlugin from "./eslint-plugin-my-plugin";
export default [
myPlugin.configs.recommended,
// or individual rules:
{
plugins: {
"my-plugin": myPlugin,
},
rules: {
"my-plugin/rule1": "error",
},
},
];
Legacy Config Plugin (.eslintrc.*)
// src/index.ts
import rule1 from "./rules/rule1";
import rule2 from "./rules/rule2";
module.exports = {
rules: {
rule1: rule1,
rule2: rule2,
},
configs: {
recommended: {
plugins: ["my-plugin"],
rules: {
"my-plugin/rule1": "error",
"my-plugin/rule2": "warn",
},
},
},
};
Usage in legacy config:
// .eslintrc.json
{
"plugins": ["my-plugin"],
"extends": ["plugin:my-plugin/recommended"]
}
Dual-Format Support
// src/index.ts
import rule1 from "./rules/rule1";
const rules = {
rule1: rule1,
};
// For flat config
const plugin = {
meta: {
name: "eslint-plugin-my-plugin",
version: "1.0.0",
},
configs: {} as Record<string, unknown>,
rules,
};
// Flat config presets
Object.assign(plugin.configs, {
recommended: {
plugins: { "my-plugin": plugin },
rules: { "my-plugin/rule1": "error" },
},
});
// Legacy config presets
const legacyConfigs = {
recommended: {
plugins: ["my-plugin"],
rules: { "my-plugin/rule1": "error" },
},
};
// Export for both systems
export default plugin;
export { rules, legacyConfigs as configs };
Project Setup Template
Directory Structure
eslint-plugin-my-plugin/
├── src/
│ ├── index.ts # Plugin entry point
│ └── rules/
│ ├── rule-name.ts # Rule implementation
│ └── __tests__/
│ └── rule-name.test.ts
├── package.json
├── tsconfig.json
└── bunfig.toml # Bun configuration (optional)
package.json
{
"name": "eslint-plugin-my-plugin",
"version": "1.0.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": ["dist"],
"scripts": {
"build": "tsc",
"test": "bun test",
"test:watch": "bun test --watch",
"lint": "eslint src"
},
"peerDependencies": {
"eslint": ">=8.0.0",
"@typescript-eslint/parser": ">=7.0.0"
},
"dependencies": {
"@typescript-eslint/utils": "^8.0.0"
},
"devDependencies": {
"@typescript-eslint/parser": "^8.0.0",
"@typescript-eslint/rule-tester": "^8.0.0",
"bun-types": "latest",
"eslint": "^9.0.0",
"typescript": "^5.0.0"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
Test Setup File (Optional)
// test-setup.ts
import { afterAll, describe, it } from "bun:test";
import { RuleTester } from "@typescript-eslint/rule-tester";
RuleTester.afterAll = afterAll;
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;
# bunfig.toml
[test]
preload = ["./test-setup.ts"]
Best Practices
Rule Implementation
- Idempotency - Fixes must be idempotent; running twice produces same result as once
- Minimal changes - Fix only what's necessary; preserve formatting and comments
- Atomic fixes - One fix per error; don't combine unrelated changes
- No runtime changes - Fixes must not alter code behavior
- Range awareness - Multiple fixes must not have overlapping ranges
Error Messages
- Be specific - "Unexpected
console.log" not "Bad code" - Explain why - "Floating Promise may cause unhandled rejection"
- Suggest action - "Use
awaitor handle with.catch()" - Use placeholders - Dynamic values via
{{ name }}syntax
Performance
- Exit early - Check preconditions before expensive operations
- Cache lookups - Store repeated
getScope()or type lookups - Use selectors - CSS selectors are optimized; prefer them over filtering
- Avoid reparse - Don't call
getText()on entire source repeatedly
TypeScript Rules
- Version alignment - Keep all
@typescript-eslint/*packages at same version - Optional type info - Don't crash if type information unavailable
- Document requirements - Note if rule requires
projectService
Troubleshooting
Common Issues and Solutions
| Issue | Cause | Solution |
|---|---|---|
| "afterAll is not defined" | RuleTester not configured for Bun | Set RuleTester.afterAll = afterAll before creating instance |
| Fix not applied | meta.fixable not set | Add fixable: "code" to meta object |
| Suggestions not showing | meta.hasSuggestions missing | Add hasSuggestions: true to meta |
| Type info unavailable | Missing parser options | Configure parserOptions.projectService |
| Test timeout | Type-aware rule slow | Use projectService.allowDefaultProject |
| "Unknown rule" | Plugin not registered | Check plugin is in config's plugins object |
| Fix creates syntax error | Invalid range or text | Use AST explorer to verify node boundaries |
Debugging Techniques
// Log AST node structure
console.log(JSON.stringify(node, null, 2));
// Check what text will be replaced
console.log(context.sourceCode.getText(node));
// Verify token boundaries
const before = context.sourceCode.getTokenBefore(node);
const after = context.sourceCode.getTokenAfter(node);
console.log({ before, after });
AST Exploration Tools
- AST Explorer: https://astexplorer.net (select @typescript-eslint/parser)
- ast-grep:
sg --lang ts -p 'pattern'for structural searches
Example Workflow
When a user requests an ESLint rule:
- Clarify the transformation - Get before/after code examples
- Identify the rule type - problem, suggestion, or layout?
- ASK about edge cases - Use questions from Edge Case Discovery section
- Confirm understanding - Summarize what the rule will do
- Write tests first - Cover main case, edge cases, idempotency
- Implement the rule - Make tests pass
- Run tests -
bun test - Document - Add rule documentation
Notes
- Keep rules focused and testable
- When in doubt, ask the user what they want to do
Repository
