Back to skills
SkillHub ClubShip Full StackFull StackTestingIntegration

eslint-plugin

Author custom ESLint plugins and rules with test-driven development. Supports flat config (ESLint 9+) and legacy formats. Uses @typescript-eslint/rule-tester for testing. Covers problem, suggestion, and layout rules including auto-fixers and type-aware rules. Use when creating or modifying ESLint rules, plugins, custom linting logic, or authoring auto-fixers.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
3
Hot score
80
Updated
March 20, 2026
Overall rating
C0.8
Composite score
0.8
Best-practice grade
B73.6

Install command

npx @skill-hub/cli install third774-dotfiles-eslint-plugins

Repository

third774/dotfiles

Skill path: opencode/skills/eslint-plugins

Author custom ESLint plugins and rules with test-driven development. Supports flat config (ESLint 9+) and legacy formats. Uses @typescript-eslint/rule-tester for testing. Covers problem, suggestion, and layout rules including auto-fixers and type-aware rules. Use when creating or modifying ESLint rules, plugins, custom linting logic, or authoring auto-fixers.

Open repository

Best for

Primary workflow: Ship Full Stack.

Technical facets: Full Stack, Testing, Integration.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: third774.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install eslint-plugin into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/third774/dotfiles before adding eslint-plugin to shared team environments
  • Use eslint-plugin for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: eslint-plugin
description: Author custom ESLint plugins and rules with test-driven development. Supports flat config (ESLint 9+) and legacy formats. Uses @typescript-eslint/rule-tester for testing. Covers problem, suggestion, and layout rules including auto-fixers and type-aware rules. Use when creating or modifying ESLint rules, plugins, custom linting logic, or authoring auto-fixers.
---

# ESLint Plugin Author

Write custom ESLint rules using TDD. This skill covers rule creation, testing, and plugin packaging.

<IMPORTANT>
1. Write tests BEFORE implementing rules (TDD)
2. ASK about edge cases before writing any code
3. Fixes MUST be idempotent (running twice = running once)
</IMPORTANT>

## 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

1. Should the rule apply to all file types or specific extensions?
2. Should it be auto-fixable, provide suggestions, or just report?
3. 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

```typescript
// 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

```typescript
// 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](references/test-patterns.md).

## Type-Aware Rules

For rules needing TypeScript type information:

```typescript
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:**

```typescript
import parser from "@typescript-eslint/parser";

const ruleTester = new RuleTester({
  languageOptions: {
    parser,
    parserOptions: {
      projectService: { allowDefaultProject: ["*.ts*"] },
      tsconfigRootDir: import.meta.dirname,
    },
  },
});
```

## Plugin Structure (Flat Config)

```typescript
// 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](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

```typescript
fixer.replaceText(node, "new")
fixer.insertTextBefore(node, "prefix")
fixer.insertTextAfter(node, "suffix")
fixer.remove(node)
fixer.replaceTextRange([start, end], "new")
```

### Common Selectors

```typescript
"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](references/code-patterns.md) - AST selectors, fixer API, reporting, context API
- [Test Patterns](references/test-patterns.md) - RuleTester setup for Bun/Vitest/Jest, test cases
- [Plugin Templates](references/plugin-templates.md) - Flat config, legacy, dual-format structures
- [Troubleshooting](references/troubleshooting.md) - 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


---

## Referenced Files

> The following files are referenced in this skill and included for context.

### references/test-patterns.md

```markdown
# ESLint Rule Test Patterns

Reference for RuleTester configuration and test case patterns.

## RuleTester Setup by Framework

### Bun

```typescript
// 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 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",
      ecmaFeatures: { jsx: true },
    },
  },
});

ruleTester.run("rule-name", rule, {
  valid: [],
  invalid: [],
});
```

**Optional preload setup** (bunfig.toml):
```toml
[test]
preload = ["./test-setup.ts"]
```

```typescript
// 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;
```

### Vitest

```typescript
// src/rules/__tests__/rule-name.test.ts
import * as vitest from "vitest";
import { RuleTester } from "@typescript-eslint/rule-tester";
import rule from "../rule-name";

// Configure BEFORE creating instance
RuleTester.afterAll = vitest.afterAll;
RuleTester.it = vitest.it;
RuleTester.itOnly = vitest.it.only;
RuleTester.describe = vitest.describe;

const ruleTester = new RuleTester({
  languageOptions: {
    parserOptions: {
      ecmaVersion: "latest",
      sourceType: "module",
    },
  },
});

ruleTester.run("rule-name", rule, {
  valid: [],
  invalid: [],
});
```

**If using `globals: true`** in vitest.config.ts, you only need:
```typescript
RuleTester.afterAll = afterAll;
```

### Jest

```typescript
// src/rules/__tests__/rule-name.test.ts
import { RuleTester } from "@typescript-eslint/rule-tester";
import rule from "../rule-name";

// Jest globals work automatically - no setup needed

const ruleTester = new RuleTester({
  languageOptions: {
    parserOptions: {
      ecmaVersion: "latest",
      sourceType: "module",
    },
  },
});

ruleTester.run("rule-name", rule, {
  valid: [],
  invalid: [],
});
```

## Type-Aware Rule Testing

For rules that use type information:

```typescript
import parser from "@typescript-eslint/parser";

const ruleTester = new RuleTester({
  languageOptions: {
    parser,
    parserOptions: {
      projectService: {
        allowDefaultProject: ["*.ts*"],
      },
      tsconfigRootDir: import.meta.dirname,
    },
  },
});
```

**Note:** Use `import.meta.dirname` (ESM) instead of `__dirname` (CJS).

## Valid Test Cases

```typescript
valid: [
  // Simple string
  `const x = 1;`,

  // Object with name (recommended for clarity)
  {
    code: `const x = 1;`,
    name: "allows basic assignment",
  },

  // With options
  {
    code: `const x = 1;`,
    options: [{ allowX: true }],
    name: "allows X when option enabled",
  },

  // With specific filename
  {
    code: `console.log('test');`,
    filename: "test.spec.ts",
    name: "allows in test files",
  },

  // With language options override
  {
    code: `<Component />`,
    languageOptions: {
      parserOptions: {
        ecmaFeatures: { jsx: true },
      },
    },
    name: "handles JSX",
  },
],
```

## Invalid Test Cases

### Basic Error

```typescript
{
  code: `const bad = 1;`,
  errors: [{ messageId: "forbidden" }],
  name: "detects forbidden pattern",
},
```

### With Fix Output

```typescript
{
  code: `const bad = 1;`,
  output: `const good = 1;`,
  errors: [{ messageId: "forbidden" }],
  name: "fixes forbidden pattern",
},
```

### With Specific Location

```typescript
{
  code: `const bad = 1;`,
  errors: [
    {
      messageId: "forbidden",
      line: 1,
      column: 7,
      endLine: 1,
      endColumn: 10,
    },
  ],
  name: "reports correct location",
},
```

### With Data Validation

```typescript
{
  code: `const bad = 1;`,
  errors: [
    {
      messageId: "forbidden",
      data: { name: "bad" },
    },
  ],
  name: "includes correct data in message",
},
```

### Multiple Errors

```typescript
{
  code: `const bad1 = 1; const bad2 = 2;`,
  errors: [
    { messageId: "forbidden" },
    { messageId: "forbidden" },
  ],
  name: "detects multiple violations",
},
```

### Assert No Fix

```typescript
{
  code: `const complexBad = 1;`,
  output: null,  // Explicitly assert no fix
  errors: [{ messageId: "tooComplex" }],
  name: "does not fix complex cases",
},
```

## Testing Suggestions

### With Suggestions

```typescript
{
  code: `const problematic = 1;`,
  errors: [
    {
      messageId: "hasOptions",
      suggestions: [
        {
          messageId: "option1",
          output: `const fixed1 = 1;`,
        },
        {
          messageId: "option2",
          data: { newName: "fixed2" },
          output: `const fixed2 = 1;`,
        },
      ],
    },
  ],
  name: "provides multiple suggestions",
},
```

### Assert No Suggestions

```typescript
{
  code: `const edge = 1;`,
  errors: [
    {
      messageId: "noSuggestions",
      suggestions: null,
    },
  ],
  name: "does not suggest for edge case",
},
```

## Required Test Categories

Every rule SHOULD have tests covering:

| Category | Purpose | Example |
|----------|---------|---------|
| Main case | Core transformation | Primary before/after |
| No-op | Unrelated code passes | Files without pattern |
| Idempotency | Fix is stable | Already-fixed code |
| Edge cases | Variations from spec | Destructuring, aliases |
| Options | Different configs | With/without flags |

## Test Organization

```typescript
ruleTester.run("rule-name", rule, {
  valid: [
    // --- Basic valid cases ---
    `const x = 1;`,

    // --- Already correct ---
    {
      code: `const correct = 1;`,
      name: "ignores already-correct code",
    },

    // --- Exempt patterns ---
    {
      code: `console.log('debug');`,
      filename: "test.spec.ts",
      name: "allows in test files",
    },
  ],

  invalid: [
    // --- Main transformation ---
    {
      code: `const bad = 1;`,
      output: `const good = 1;`,
      errors: [{ messageId: "errorId" }],
      name: "main case",
    },

    // --- Edge cases ---
    {
      code: `const { bad } = obj;`,
      errors: [{ messageId: "errorId" }],
      name: "handles destructuring",
    },

    // --- With options ---
    {
      code: `const bad = 1;`,
      options: [{ strict: true }],
      errors: [{ messageId: "strictError" }],
      name: "strict mode error",
    },
  ],
});
```

## Running Tests

```bash
# Bun
bun test src/rules/__tests__/rule-name.test.ts
bun test --watch src/rules/__tests__/rule-name.test.ts

# Vitest
npx vitest run src/rules/__tests__/rule-name.test.ts
npx vitest src/rules/__tests__/rule-name.test.ts  # watch mode

# Jest
npx jest src/rules/__tests__/rule-name.test.ts
npx jest --watch src/rules/__tests__/rule-name.test.ts
```

```

### references/plugin-templates.md

```markdown
# ESLint Plugin Templates

Reference for plugin structure and configuration patterns.

## 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               # If using Bun
```

## Rule Implementation Template

```typescript
// 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",
    docs: {
      description: "Description of what the rule does",
    },
    fixable: "code",
    hasSuggestions: true,
    messages: {
      errorId: "Error message with {{ placeholder }}",
      suggestionId: "Suggestion message",
    },
    schema: [
      {
        type: "object",
        properties: {
          optionName: { type: "boolean" },
        },
        additionalProperties: false,
      },
    ],
  },
  defaultOptions: [{ optionName: false }],

  create(context, [options]) {
    return {
      Identifier(node) {
        // Rule logic
      },
    };
  },
});
```

## Flat Config Plugin (ESLint 9+)

```typescript
// src/index.ts
import { defineConfig } from "eslint/config";
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: defineConfig([
    {
      plugins: {
        "my-plugin": plugin,
      },
      rules: {
        "my-plugin/rule1": "error",
        "my-plugin/rule2": "warn",
      },
    },
  ]),
});

export default plugin;
```

**Usage:**

```javascript
// eslint.config.js
import { defineConfig } from "eslint/config";
import myPlugin from "eslint-plugin-my-plugin";

export default defineConfig([
  // Use preset
  myPlugin.configs.recommended,

  // Or configure individually
  {
    plugins: {
      "my-plugin": myPlugin,
    },
    rules: {
      "my-plugin/rule1": "error",
    },
  },
]);
```

## Legacy Config Plugin (.eslintrc.*)

```typescript
// 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:**

```json
// .eslintrc.json
{
  "plugins": ["my-plugin"],
  "extends": ["plugin:my-plugin/recommended"]
}
```

## Dual-Format Plugin (Both Configs)

```typescript
// src/index.ts
import { defineConfig } from "eslint/config";
import rule1 from "./rules/rule1";

const rules = {
  "rule1": rule1,
};

// Flat config plugin object
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: defineConfig([
    {
      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 };
```

## package.json

```json
{
  "name": "eslint-plugin-my-plugin",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": ["dist"],
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  },
  "scripts": {
    "build": "tsc",
    "test": "bun test",
    "test:watch": "bun test --watch",
    "lint": "eslint src"
  },
  "peerDependencies": {
    "eslint": ">=9.0.0",
    "@typescript-eslint/parser": ">=8.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

```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"]
}
```

## Local Plugin (No Package)

For rules specific to a single project:

```javascript
// eslint.config.js
import { defineConfig } from "eslint/config";
import myRule from "./eslint-rules/my-rule.js";

export default defineConfig([
  {
    plugins: {
      local: {
        rules: {
          "my-rule": myRule,
        },
      },
    },
    rules: {
      "local/my-rule": "error",
    },
  },
]);
```

## Type-Aware Plugin Config

For plugins with rules using TypeScript type information:

```javascript
// eslint.config.js
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
import myPlugin from "eslint-plugin-my-plugin";

export default defineConfig([
  tseslint.configs.recommendedTypeChecked,
  {
    languageOptions: {
      parserOptions: {
        projectService: true,
        tsconfigRootDir: import.meta.dirname,
      },
    },
  },
  myPlugin.configs.recommended,
]);
```

```

### references/code-patterns.md

```markdown
# ESLint Rule Code Patterns

Reference for AST traversal, reporting, and fixing patterns.

## Rule Meta Object

```typescript
meta: {
  type: "problem",        // "problem" | "suggestion" | "layout"
  docs: {
    description: "What the rule does",
  },
  fixable: "code",        // Include only if auto-fixable
  hasSuggestions: true,   // Include only if has suggestions
  messages: {
    errorId: "Error with {{ placeholder }}",
    suggestId: "Suggestion message",
  },
  schema: [
    {
      type: "object",
      properties: {
        optionName: { type: "boolean" },
      },
      additionalProperties: false,
    },
  ],
}
```

| Type | Use Case |
|------|----------|
| `problem` | Code likely to cause errors |
| `suggestion` | Style improvement, not an error |
| `layout` | Whitespace, formatting |

## Context API

```typescript
create(context) {
  const options = context.options[0] ?? {};
  const sourceCode = context.sourceCode;

  // Get text
  const text = sourceCode.getText(node);

  // Get tokens
  const before = sourceCode.getTokenBefore(node);
  const after = sourceCode.getTokenAfter(node);

  // Get comments
  const comments = sourceCode.getCommentsBefore(node);

  // Get scope
  const scope = sourceCode.getScope(node);

  // Get ancestors
  const ancestors = sourceCode.getAncestors(node);

  return { /* visitors */ };
}
```

## AST Selectors

```typescript
create(context) {
  return {
    // Simple node type
    Identifier(node) { },

    // Exit visitor (after children processed)
    "FunctionDeclaration:exit"(node) { },

    // Attribute condition
    "CallExpression[callee.name='forbidden']"(node) { },

    // Multiple types
    "FunctionDeclaration, ArrowFunctionExpression"(node) { },

    // Child selector
    "CallExpression > Identifier[name='console']"(node) { },

    // Descendant selector
    "CallExpression Identifier[name='log']"(node) { },

    // Has selector (parent has matching child)
    "ImportDeclaration:has(ImportSpecifier[imported.name='deprecated'])"(node) { },

    // Literal value
    "Literal[value=true]"(node) { },

    // Not selector
    "CallExpression:not([callee.name='allowed'])"(node) { },
  };
}
```

## Reporting

### Basic Report

```typescript
context.report({
  node,
  messageId: "errorId",
});
```

### With Data Placeholders

```typescript
context.report({
  node,
  messageId: "errorWithData",
  data: {
    name: node.name,
    expected: "something",
  },
});
```

### With Specific Location

```typescript
context.report({
  node,
  loc: node.loc.start,  // or { line, column }
  messageId: "errorId",
});
```

### With Fix

```typescript
context.report({
  node,
  messageId: "fixable",
  fix(fixer) {
    return fixer.replaceText(node, "replacement");
  },
});
```

### With Multiple Fixes

```typescript
context.report({
  node,
  messageId: "multipleChanges",
  fix(fixer) {
    return [
      fixer.insertTextBefore(node, "prefix"),
      fixer.insertTextAfter(node, "suffix"),
    ];
  },
});
```

### With Suggestions

```typescript
context.report({
  node,
  messageId: "hasSuggestions",
  suggest: [
    {
      messageId: "suggestion1",
      fix(fixer) {
        return fixer.replaceText(node, "option1");
      },
    },
    {
      messageId: "suggestion2",
      data: { replacement: "option2" },
      fix(fixer) {
        return fixer.replaceText(node, "option2");
      },
    },
  ],
});
```

## Fixer API

```typescript
fix(fixer) {
  // Insert
  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");
}
```

**Fix Constraints:**
- Fixes MUST be idempotent (running twice = running once)
- Multiple fixes MUST NOT have overlapping ranges
- Fixes MUST NOT change code behavior

## Type-Aware Rules (typescript-eslint v6+)

```typescript
import { ESLintUtils } from "@typescript-eslint/utils";
import * as ts from "typescript";

create(context) {
  const services = ESLintUtils.getParserServices(context);

  return {
    CallExpression(node) {
      // Get type directly (v6+ simplified API)
      const type = services.getTypeAtLocation(node);

      // Check type string
      if (services.program.getTypeChecker().typeToString(type) === "Promise<void>") {
        // Handle Promise
      }

      // Check symbol flags
      if (type.symbol?.flags & ts.SymbolFlags.Enum) {
        // Handle enum
      }

      // Get call signatures
      const signatures = type.getCallSignatures();
      if (signatures.length > 0) {
        const returnType = signatures[0].getReturnType();
      }
    },
  };
}
```

**Note:** The v6+ API (`services.getTypeAtLocation(node)`) replaces the verbose v5 pattern:
```typescript
// OLD (v5) - don't use
const checker = services.program.getTypeChecker();
const tsNode = services.esTreeNodeToTSNodeMap.get(node);
const type = checker.getTypeAtLocation(tsNode);
```

## Common Node Types

| Node Type | Represents | Example |
|-----------|------------|---------|
| `Identifier` | Names | `foo`, `myVar` |
| `CallExpression` | Function calls | `foo()`, `obj.method()` |
| `MemberExpression` | Property access | `obj.prop`, `arr[0]` |
| `ImportDeclaration` | Import statements | `import { x } from 'y'` |
| `ImportSpecifier` | Named imports | `{ x }` in import |
| `ImportDefaultSpecifier` | Default imports | `x` in `import x from` |
| `VariableDeclaration` | Variable declarations | `const x = 1` |
| `VariableDeclarator` | Individual variable | `x = 1` part |
| `FunctionDeclaration` | Named functions | `function foo() {}` |
| `ArrowFunctionExpression` | Arrow functions | `() => {}` |
| `ObjectExpression` | Object literals | `{ a: 1 }` |
| `ArrayExpression` | Array literals | `[1, 2]` |
| `Literal` | Primitives | `'str'`, `42`, `true` |
| `TemplateLiteral` | Template strings | `` `hello ${name}` `` |
| `JSXElement` | JSX tags | `<div>` |
| `TSTypeAnnotation` | Type annotations | `: string` |

```

### references/troubleshooting.md

```markdown
# ESLint Rule Troubleshooting

Common issues and debugging techniques.

## Common Issues

| Issue | Cause | Solution |
|-------|-------|----------|
| "afterAll is not defined" | RuleTester not configured for test runner | 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 |
| `__dirname` undefined | ESM module context | Use `import.meta.dirname` instead |
| Parser not found | Using `require()` in ESM | Use `import parser from '@typescript-eslint/parser'` |

## Debugging Techniques

### Log AST Node Structure

```typescript
create(context) {
  return {
    CallExpression(node) {
      console.log(JSON.stringify(node, null, 2));
    },
  };
}
```

### Check Text Being Replaced

```typescript
create(context) {
  return {
    Identifier(node) {
      console.log("Text:", context.sourceCode.getText(node));
      console.log("Range:", node.range);
    },
  };
}
```

### Verify Token Boundaries

```typescript
create(context) {
  const sourceCode = context.sourceCode;
  return {
    CallExpression(node) {
      const before = sourceCode.getTokenBefore(node);
      const after = sourceCode.getTokenAfter(node);
      console.log({ before, node, after });
    },
  };
}
```

### Check Scope Information

```typescript
create(context) {
  return {
    Identifier(node) {
      const scope = context.sourceCode.getScope(node);
      console.log("Scope type:", scope.type);
      console.log("Variables:", scope.variables.map(v => v.name));
    },
  };
}
```

## AST Exploration Tools

### AST Explorer (Web)

https://astexplorer.net

1. Select parser: `@typescript-eslint/parser`
2. Paste code sample
3. Click nodes to see structure
4. Use for understanding node types and properties

### ast-grep (CLI)

```bash
# Find pattern in codebase
sg --lang ts -p 'console.log($MSG)'

# Interactive mode
sg --lang ts -p 'console.$METHOD($ARGS)' --interactive

# Show AST for file
sg --lang ts --debug-query 'console.log' file.ts
```

### ESLint Debug Mode

```bash
# See which rules are running
DEBUG=eslint:* npx eslint file.ts

# See rule timing
TIMING=1 npx eslint file.ts
```

## Fix Debugging

### Verify Fix Range

```typescript
fix(fixer) {
  console.log("Node range:", node.range);
  console.log("Node text:", context.sourceCode.getText(node));
  return fixer.replaceText(node, "replacement");
}
```

### Multiple Fix Conflict

If multiple fixes conflict (overlapping ranges), only one applies. Debug by:

```typescript
fix(fixer) {
  const fixes = [
    fixer.insertTextBefore(node, "a"),
    fixer.insertTextAfter(node, "b"),
  ];
  console.log("Fixes:", fixes.map(f => ({ range: f.range, text: f.text })));
  return fixes;
}
```

### Fix Not Applying

1. Check `meta.fixable: "code"` is set
2. Check fix function returns a value (not undefined)
3. Check ranges don't overlap with other fixes
4. Run with `--fix-dry-run` to see what would change

```bash
npx eslint --fix-dry-run --format json file.ts
```

## Type-Aware Rule Issues

### "You have used a rule which requires parserServices"

The rule uses type information but the config doesn't provide it.

**Fix:** Add to eslint.config.js:

```javascript
{
  languageOptions: {
    parserOptions: {
      projectService: true,
      tsconfigRootDir: import.meta.dirname,
    },
  },
}
```

### Type Information is Wrong

1. Check tsconfig.json includes the file
2. Check `tsconfigRootDir` points to correct location
3. Restart ESLint (type info is cached)

### Slow Type-Aware Rules

Type-aware rules are slower because they load the TypeScript program.

**Optimizations:**
- Use `projectService.allowDefaultProject` in tests
- Limit type-aware rules to specific file patterns
- Consider if type info is actually needed

## Test Debugging

### See Full Error Output

```typescript
// In test file
ruleTester.run("rule-name", rule, {
  invalid: [
    {
      code: `bad code`,
      errors: [
        {
          messageId: "errorId",
          // Add all properties to see what's wrong
          line: 1,
          column: 1,
          endLine: 1,
          endColumn: 10,
          data: { name: "expected" },
        },
      ],
    },
  ],
});
```

### Test Expected Output Mismatch

If `output` doesn't match, the test shows a diff. Common causes:

1. **Whitespace:** Output must match exactly, including newlines
2. **Quotes:** Single vs double quotes matter
3. **Semicolons:** Check if rule adds/removes them

### Isolate Failing Test

```typescript
// Bun
it.only("test name", ...)

// Vitest
it.only("test name", ...)

// Jest
it.only("test name", ...)
```

Or use `RuleTester.itOnly`:

```typescript
RuleTester.itOnly = it.only;
```

## Performance Issues

### Rule is Slow

1. **Exit early:** Check preconditions before expensive operations
2. **Cache lookups:** Store repeated `getScope()` or type lookups
3. **Use selectors:** CSS selectors are optimized; prefer over manual filtering
4. **Avoid reparse:** Don't call `getText()` on entire source repeatedly

```typescript
// BAD: Checks all identifiers
Identifier(node) {
  if (node.name === "target") { /* ... */ }
}

// GOOD: Selector filters first
"Identifier[name='target']"(node) {
  /* ... */
}
```

### Tests are Slow

1. Use `projectService.allowDefaultProject` for type-aware tests
2. Run specific test file, not entire suite
3. Consider if type-awareness is needed for all tests

```

eslint-plugin | SkillHub