Cómo crear un plugin de ESLint personalizado
Cómo crear reglas de ESLint personalizadas: selectores de AST, pruebas de reglas, auto-fixers y la publicación de un plugin compartido para tu equipo.

Las reglas integradas de ESLint y los plugins más populares cubren los problemas habituales de calidad de código: variables sin usar, manejo de errores ausente, consistencia de estilo. Pero todo equipo tiene convenciones que ningún plugin público impone. "Usa siempre nuestro logger en lugar de console.log". "Nunca llames al SDK de analítica fuera de los manejadores de eventos". "Los manejadores de rutas de API deben validar el cuerpo de la petición con nuestro validador de esquemas".
Las reglas personalizadas de ESLint convierten estas convenciones propias del equipo en comprobaciones automatizadas. En lugar de detectar violaciones en la revisión de código, el editor las detecta mientras escribes. Esta guía construye un plugin personalizado desde cero, incluyendo la escritura de reglas, la navegación por el AST, la auto-corrección y las pruebas.
Entendiendo el AST
Las reglas de ESLint operan sobre árboles de sintaxis abstracta (AST). Cuando ESLint analiza tu código, construye un árbol donde cada nodo representa un elemento sintáctico: declaraciones de funciones, asignaciones de variables, llamadas a métodos, condicionales.
// This code:
console.log("hello");
// Produces this AST (simplified):
const ast = {
type: "Program",
body: [
{
type: "ExpressionStatement",
expression: {
type: "CallExpression",
callee: {
type: "MemberExpression",
object: { type: "Identifier", name: "console" },
property: { type: "Identifier", name: "log" },
},
arguments: [
{ type: "Literal", value: "hello" },
],
},
},
],
};
// ESLint rules are visitors that react to specific node types.
// When ESLint encounters a CallExpression node, it calls your
// rule's CallExpression handler with that node.Configurando el plugin
# Project structure
mkdir eslint-plugin-ourteam && cd eslint-plugin-ourteam
npm init -y
npm install -D eslint @types/eslint typescript vitest{
"name": "eslint-plugin-ourteam",
"version": "1.0.0",
"main": "dist/index.js",
"files": ["dist"],
"scripts": {
"build": "tsc",
"test": "vitest"
}
}// src/index.ts — plugin entry point
import { noConsoleLog } from "./rules/no-console-log";
import { requireBodyValidation } from "./rules/require-body-validation";
import { useCustomLogger } from "./rules/use-custom-logger";
const plugin = {
rules: {
"no-console-log": noConsoleLog,
"require-body-validation": requireBodyValidation,
"use-custom-logger": useCustomLogger,
},
};
export default plugin;Escribiendo tu primera regla
Regla: "Usa nuestro logger personalizado en lugar de console.log en el código de producción."
// src/rules/use-custom-logger.ts
import { Rule } from "eslint";
export const useCustomLogger: Rule.RuleModule = {
meta: {
type: "suggestion",
docs: {
description: "Enforce using @ourteam/logger instead of console methods",
},
fixable: "code",
messages: {
useLogger:
"Use logger.{{method}}() from @ourteam/logger instead of console.{{method}}()",
},
schema: [],
},
create(context) {
// Map console methods to logger methods
const methodMap: Record<string, string> = {
log: "info",
info: "info",
warn: "warn",
error: "error",
debug: "debug",
};
return {
// This visitor fires for every MemberExpression node
MemberExpression(node) {
if (
node.object.type === "Identifier" &&
node.object.name === "console" &&
node.property.type === "Identifier" &&
node.property.name in methodMap
) {
const consoleMethod = node.property.name;
const loggerMethod = methodMap[consoleMethod];
context.report({
node,
messageId: "useLogger",
data: { method: consoleMethod },
fix(fixer) {
// Replace "console.log" with "logger.info"
return fixer.replaceText(node, `logger.${loggerMethod}`);
},
});
}
},
};
},
};Una regla más compleja: exigir la validación del cuerpo
Regla: "Todo manejador de rutas de Express con un método POST/PUT/PATCH debe llamar a validateBody() antes de acceder a req.body."
// src/rules/require-body-validation.ts
import { Rule } from "eslint";
import { Node } from "estree";
export const requireBodyValidation: Rule.RuleModule = {
meta: {
type: "problem",
docs: {
description: "Require validateBody() before accessing req.body in route handlers",
},
messages: {
missingValidation:
"req.body accessed without calling validateBody() first. " +
"Add validateBody(schema) before using request body data.",
},
schema: [],
},
create(context) {
return {
// Match: app.post("/path", handler) or router.put("/path", handler)
'CallExpression[callee.property.name=/^(post|put|patch)$/]'(
node: Rule.Node
) {
const callExpr = node as unknown as {
arguments: Node[];
};
// Find the handler function (last argument)
const handler = callExpr.arguments.at(-1);
if (!handler) return;
if (
handler.type !== "ArrowFunctionExpression" &&
handler.type !== "FunctionExpression"
) {
return;
}
const body =
handler.body.type === "BlockStatement"
? handler.body.body
: [];
let hasValidation = false;
let reqBodyAccess: Rule.Node | null = null;
for (const stmt of body) {
// Check if validateBody is called
const source = context.getSourceCode().getText(stmt as Rule.Node);
if (source.includes("validateBody")) {
hasValidation = true;
}
// Check if req.body is accessed
if (!reqBodyAccess && source.includes("req.body")) {
reqBodyAccess = stmt as Rule.Node;
}
}
if (reqBodyAccess && !hasValidation) {
context.report({
node: reqBodyAccess,
messageId: "missingValidation",
});
}
},
};
},
};Probando las reglas
ESLint proporciona un RuleTester que facilita las pruebas de reglas. Cada caso de prueba proporciona código y los errores esperados (o su ausencia).
// src/rules/__tests__/use-custom-logger.test.ts
import { RuleTester } from "eslint";
import { useCustomLogger } from "../use-custom-logger";
import { describe, it } from "vitest";
const ruleTester = new RuleTester({
parserOptions: { ecmaVersion: 2020, sourceType: "module" },
});
describe("use-custom-logger", () => {
it("should enforce using custom logger", () => {
ruleTester.run("use-custom-logger", useCustomLogger, {
valid: [
// These should NOT trigger the rule
'logger.info("message")',
'logger.error("failed", error)',
'logger.warn("deprecated")',
'someObject.log("this is fine")', // Not console
],
invalid: [
{
code: 'console.log("hello")',
errors: [{ messageId: "useLogger" }],
output: 'logger.info("hello")', // Verify auto-fix output
},
{
code: 'console.error("failed", err)',
errors: [{ messageId: "useLogger" }],
output: 'logger.error("failed", err)',
},
{
code: 'console.warn("deprecated")',
errors: [{ messageId: "useLogger" }],
output: 'logger.warn("deprecated")',
},
],
});
});
});// ❌ Testing only the happy path
const weakTests = {
valid: ['logger.info("ok")'],
invalid: [
{ code: 'console.log("bad")', errors: [{ messageId: "useLogger" }] },
],
};
// ✅ Testing edge cases thoroughly
const thoroughTests = {
valid: [
'logger.info("message")', // Correct usage
'someObject.log("not console")', // Different object
'console.table(data)', // Method not in our map
'const console = {}; console.log()', // Shadowed console
],
invalid: [
// All console methods that should trigger
{ code: 'console.log("x")', errors: 1, output: 'logger.info("x")' },
{ code: 'console.info("x")', errors: 1, output: 'logger.info("x")' },
{ code: 'console.warn("x")', errors: 1, output: 'logger.warn("x")' },
{ code: 'console.error("x")', errors: 1, output: 'logger.error("x")' },
{ code: 'console.debug("x")', errors: 1, output: 'logger.debug("x")' },
// Multiple violations in one file
{
code: 'console.log("a"); console.error("b")',
errors: 2,
},
],
};Publicando y usando el plugin
# Build the plugin
npm run build
# For internal teams: publish to your registry
npm publish --registry https://npm.internal.company.com
# Or install directly from git
npm install -D git+https://github.com/ourteam/eslint-plugin-ourteam.git// eslint.config.mjs — using the plugin in flat config
import ourteamPlugin from "eslint-plugin-ourteam";
export default [
{
plugins: {
ourteam: ourteamPlugin,
},
rules: {
"ourteam/use-custom-logger": "error",
"ourteam/require-body-validation": "error",
"ourteam/no-console-log": "warn",
},
},
{
// Disable logger rule in test files
files: ["**/*.test.ts", "**/*.spec.ts"],
rules: {
"ourteam/use-custom-logger": "off",
},
},
];Conclusiones clave
- Las reglas personalizadas de ESLint automatizan el feedback de la revisión de código — si sigues dejando el mismo comentario en las revisiones, escribe una regla; el editor lo detectará antes de que se abra el PR
- Las reglas son visitantes del AST — cada regla registra manejadores para tipos de nodo (CallExpression, MemberExpression); ESLint llama a tu manejador cuando encuentra ese tipo de nodo
- Usa AST Explorer para entender la estructura de los nodos — pega el patrón de código objetivo en astexplorer.net para ver los tipos de nodo exactos y las propiedades que tu regla debe coincidir
- Los auto-fixers aumentan la adopción — una regla con soporte de
--fixse usa; una regla que solo advierte se ignora; implementa fixers para transformaciones deterministas - Prueba los casos límite, no solo el camino feliz — las variables ocultadas (shadowed), las diferentes formas de objetos y las cadenas de métodos que parecen similares pero no son console necesitan cobertura de pruebas


