Criando uma regra de ESLint personalizada com TypeScript

1. Fundamentos do ecossistema de regras ESLint

O ESLint é um dos linters mais populares do ecossistema JavaScript/TypeScript, e sua arquitetura baseada em plugins permite que desenvolvedores criem regras personalizadas para atender necessidades específicas de seus projetos. Cada regra no ESLint segue um ciclo de vida bem definido: create (criação da regra), meta (metadados da regra) e fix (autocorreção opcional).

Um plugin ESLint é um pacote npm que exporta um conjunto de regras. A estrutura básica de um plugin inclui:

// plugin.ts
import type { Rule } from '@typescript-eslint/utils';

export const rules = {
  'no-any-params': {
    meta: { /* ... */ },
    create: (context) => { /* ... */ }
  }
};

As regras podem ser classificadas em três categorias principais:
- Regras de sintaxe: verificam padrões de código (ex: ponto-e-vírgula obrigatório)
- Regras semânticas: analisam o significado do código (ex: variáveis não utilizadas)
- Regras de estilo: garantem consistência visual (ex: indentação)

2. Configurando o ambiente de desenvolvimento

Para começar, crie um projeto com as dependências necessárias:

mkdir eslint-plugin-custom
cd eslint-plugin-custom
npm init -y
npm install --save-dev typescript @typescript-eslint/utils @typescript-eslint/rule-tester jest ts-jest @types/jest

Estrutura de pastas recomendada:

eslint-plugin-custom/
├── src/
│   ├── rules/
│   │   └── no-any-params.ts
│   └── index.ts
├── tests/
│   └── rules/
│       └── no-any-params.test.ts
├── docs/
│   └── rules/
│       └── no-any-params.md
├── tsconfig.json
├── jest.config.js
└── package.json

Configure o tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2019",
    "module": "commonjs",
    "lib": ["ES2019"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "tests"]
}

3. Anatomia de uma regra personalizada com TypeScript

Cada regra deve exportar um objeto com meta e create. O meta contém informações como tipo, documentação e esquema de configuração:

import type { Rule } from '@typescript-eslint/utils';

type MessageIds = 'noAnyParam' | 'suggestion';

const noAnyParamsRule: Rule.RuleModule = {
  meta: {
    type: 'suggestion',
    docs: {
      description: 'Proíbe o uso de `any` em parâmetros de função',
      recommended: 'warn',
      url: 'https://example.com/docs/no-any-params'
    },
    schema: [
      {
        type: 'object',
        properties: {
          allowCallback: { type: 'boolean' }
        },
        additionalProperties: false
      }
    ],
    messages: {
      noAnyParam: 'Parâmetro "{{param}}" não deve ser do tipo `any`.',
      suggestion: 'Considere usar um tipo específico ou `unknown`.'
    },
    fixable: 'code'
  },
  create(context) {
    return {
      // Visitors serão implementados aqui
    };
  }
};

A assinatura do create recebe um context tipado com RuleContext e retorna um objeto com visitors que percorrem a AST.

4. Navegando pela AST do TypeScript com @typescript-eslint/utils

O @typescript-eslint/utils fornece acesso à AST do TypeScript através do TSESTree. Para navegar pelos nós, utilizamos funções auxiliares como isIdentifier, isCallExpression e isTypeNode:

import { ASTUtils, TSESTree } from '@typescript-eslint/utils';

// Verificando se um nó é uma declaração de função
function isFunctionDeclaration(node: TSESTree.Node): node is TSESTree.FunctionDeclaration {
  return node.type === 'FunctionDeclaration';
}

// Acessando informações de tipo com parserServices
function getTypeAtPosition(context: Rule.RuleContext, node: TSESTree.Node) {
  const parserServices = context.sourceCode.parserServices;
  if (!parserServices?.hasFullTypeInformation) {
    return null;
  }
  return parserServices.getTypeAtLocation(node);
}

5. Implementando a lógica da regra: validação e relato

Vamos implementar uma regra que proíbe any em parâmetros de função:

import { ASTUtils, TSESTree, Rule } from '@typescript-eslint/utils';

type MessageIds = 'noAnyParam';

const noAnyParamsRule: Rule.RuleModule = {
  meta: {
    type: 'suggestion',
    docs: {
      description: 'Proíbe o uso de `any` em parâmetros de função',
      recommended: 'warn'
    },
    schema: [],
    messages: {
      noAnyParam: 'Parâmetro "{{param}}" não deve ser do tipo `any`. Use um tipo específico ou `unknown`.'
    },
    fixable: 'code'
  },
  create(context) {
    return {
      FunctionDeclaration(node: TSESTree.FunctionDeclaration) {
        node.params.forEach((param) => {
          if (param.typeAnnotation) {
            const typeAnnotation = param.typeAnnotation.typeAnnotation;
            if (typeAnnotation.type === 'TSAnyKeyword') {
              context.report({
                node: param,
                messageId: 'noAnyParam',
                data: {
                  param: param.type === 'Identifier' ? param.name : 'parâmetro'
                }
              });
            }
          }
        });
      },
      ArrowFunctionExpression(node: TSESTree.ArrowFunctionExpression) {
        // Lógica similar para arrow functions
        node.params.forEach((param) => {
          if (param.typeAnnotation) {
            const typeAnnotation = param.typeAnnotation.typeAnnotation;
            if (typeAnnotation.type === 'TSAnyKeyword') {
              context.report({
                node: param,
                messageId: 'noAnyParam',
                data: {
                  param: param.type === 'Identifier' ? param.name : 'parâmetro'
                }
              });
            }
          }
        });
      }
    };
  }
};

6. Adicionando autofix com TypeScript

Para adicionar um autofix que substitui any por unknown, utilizamos o fixer:

create(context) {
  return {
    FunctionDeclaration(node: TSESTree.FunctionDeclaration) {
      node.params.forEach((param) => {
        if (param.typeAnnotation?.typeAnnotation.type === 'TSAnyKeyword') {
          context.report({
            node: param,
            messageId: 'noAnyParam',
            data: {
              param: param.type === 'Identifier' ? param.name : 'parâmetro'
            },
            fix: (fixer) => {
              const typeNode = param.typeAnnotation!.typeAnnotation;
              return fixer.replaceText(typeNode, 'unknown');
            }
          });
        }
      });
    }
  };
}

O fixer oferece métodos como:
- fix.replaceText(node, text): substitui o texto de um nó
- fix.insertTextBefore(node, text): insere texto antes de um nó
- fix.remove(node): remove um nó completamente

7. Testando a regra com TypeScript e Jest

Configure o RuleTester do @typescript-eslint/rule-tester:

// tests/rules/no-any-params.test.ts
import { RuleTester } from '@typescript-eslint/rule-tester';
import noAnyParamsRule from '../../src/rules/no-any-params';

const ruleTester = new RuleTester({
  parser: require.resolve('@typescript-eslint/parser'),
  parserOptions: {
    ecmaVersion: 2020,
    sourceType: 'module'
  }
});

ruleTester.run('no-any-params', noAnyParamsRule, {
  valid: [
    'function foo(x: string) {}',
    'const bar = (y: number) => y * 2;',
    'function baz<T>(param: T) {}'
  ],
  invalid: [
    {
      code: 'function foo(x: any) {}',
      errors: [{ messageId: 'noAnyParam' as const }],
      output: 'function foo(x: unknown) {}'
    },
    {
      code: 'const bar = (y: any) => y;',
      errors: [{ messageId: 'noAnyParam' as const }],
      output: 'const bar = (y: unknown) => y;'
    }
  ]
});

8. Publicando e documentando o plugin

Configure o package.json para publicação:

{
  "name": "eslint-plugin-no-any-params",
  "version": "1.0.0",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": ["dist"],
  "scripts": {
    "build": "tsc",
    "test": "jest",
    "prepublishOnly": "npm run build && npm test"
  },
  "peerDependencies": {
    "@typescript-eslint/parser": "^6.0.0",
    "eslint": "^8.0.0"
  }
}

Documente a regra com exemplos práticos:

# `no-any-params`

Proíbe o uso de `any` em parâmetros de função.

## Regra

### Código inválido
```typescript
function process(data: any) {}

Código válido

function process(data: string) {}
function process(data: unknown) {}

```

Siga boas práticas de versionamento semântico (semver) e considere integrar o plugin em monorepos com ferramentas como Lerna ou Nx para gerenciar múltiplos pacotes.

Referências