Every team has that one bug that keeps coming back—the one that slips past code review, survives testing, and somehow makes it to production. You might have even added a lint rule for it, only to find the problem persisting. That is because your linter is lying: out-of-the-box ESLint configs are built for general JavaScript best practices, not for the subtle, context-sensitive mistakes your specific codebase keeps producing. The fix is not to switch tools or turn up severity levels—it is to write your own ESLint plugins with custom AST rules that target the exact patterns your team repeats.
Why Off-the-Shelf Rules Are Not Enough Anymore
In 2026, with AI-assisted code generation and increasingly modular codebases, the volume of code moving through pull requests has exploded. General-purpose lint rules cannot possibly anticipate every anti-pattern that emerges from your particular architecture, naming conventions, and team habits. Your linter is doing exactly what it was told: catching the mistakes that are common enough to be codified in a public plugin. But the hidden bugs—the ones that cost you hours of debugging—are often the result of patterns that are unique to your environment.
Consider the difference between a rule that flags console.log and a rule that knows your API layer expects a specific idempotency key format. The first is useful. The second is transformative. Custom AST rules let you encode team knowledge directly into the development workflow, so that a mistake is caught at the moment it is typed, not after it is deployed.
The Hidden Bugs That Are Unique to Your Codebase
Recurring mistakes usually have a familiar shape. They may involve:
- Calling an internal utility with arguments in the wrong order
- Forgetting to handle a specific error type that your backend often returns
- Using a deprecated internal API that is still exported for backward compatibility
- Constructing URLs incorrectly by concatenating strings instead of using the shared route builder
- Assuming a certain state shape in custom React hooks that changed six months ago
These bugs are invisible to standard ESLint rules because they depend on semantic knowledge that is local to your project. A custom AST rule can encode the exact structure of your utility functions, the expected types of your contracts, and the forbidden call patterns—all in a way that runs in milliseconds as part of the existing linting workflow.
AST Basics: What Your Linter Actually Sees
Before writing a custom rule, it helps to understand what ESLint is looking at. ESLint does not work with raw source text; it parses JavaScript into an Abstract Syntax Tree (AST). That tree represents every function call, variable declaration, and control flow statement as a structured node. When you write a rule, you are essentially defining a pattern that matches nodes in that tree.
Tools like AST Explorer make this process visual. You can paste in a snippet of code and immediately see its AST representation. This is your map. Once you can identify the node type for the pattern you want to catch—say CallExpression or MemberExpression—you can write a rule that visits those nodes and applies custom logic.
Writing Your First Custom Rule: From Pattern to AST Selector
The easiest way to write custom rules is to use the create function in an ESLint plugin. Here is a minimal structure:
module.exports = {
rules: {
"no-dangerous-route-construction": {
create(context) {
return {
CallExpression(node) {
// Check if the call is to the route builder
}
};
}
}
}
};
Inside the visitor method, you have access to the full node. You can inspect the callee name, the arguments, and the surrounding context. If the pattern matches an anti-pattern, you call context.report() to surface the problem to the developer.
Let’s say your team frequently forgets to use the shared buildApiPath function and instead pastes strings together. A rule can check every call to fetch and verify that the first argument is not a string literal with a certain prefix. You can also make the rule smart enough to only flag calls that should have used the shared helper, based on the presence of a hardcoded path segment that matches your API namespace.
A Practical Example: Catching a Recurring useEffect Bug
One of the most common hidden bugs in React codebases is a useEffect that depends on an object or array created during render, causing infinite re-fetching. Standard hooks lint rules often miss this because the dependency array is syntactically valid. You can write a custom rule that flags any useEffect call where the dependency array contains an object literal or array expression.
module.exports = {
rules: {
"no-new-object-in-effect": {
create(context) {
return {
CallExpression(node) {
if (node.callee.name === 'useEffect') {
const [deps] = node.arguments.slice(-1);
if (deps && deps.type === 'ArrayExpression') {
deps.elements.forEach(el => {
if (el && el.type === 'ObjectExpression') {
context.report({
node: el,
message: 'Avoid new objects in useEffect dependencies; use a constant or memoized value.'
});
}
});
}
}
}
};
}
}
}
};
This is a small rule, but it catches a bug class that often takes hours to debug. For a team that works heavily with React data fetching, this single plugin can save days over a quarter.
Moving Beyond Simple Node Matches
Most interesting rules require more than matching a node type. You need to analyze scope, types, and relationships between nodes. ESLint provides a scope manager, and you can also combine custom rules with TypeScript’s type information by using @typescript-eslint/utils. This opens the door to rules that understand whether a variable is a string or a Promise, or whether a function is intended to be called in an event handler.
For example, if your team has a custom useAsync hook that returns { data, error, isLoading }, you can write a rule that flags any destructuring that renames data to something else, because that often leads to confusion in larger components. The rule can use Identifier nodes and TypeScript’s type checker to confirm the source of the variable.
Testing and Shipping Your Plugin Without Friction
Once you have written a few rules, the next challenge is making sure they are reliable and easy to maintain. Use ESLint’s built-in RuleTester to write unit tests for each rule. This is not optional—your rules will evolve, and tests ensure you do not accidentally flag valid code.
When the plugin is ready, publish it as an internal npm package or keep it in a shared repository. Configure ESLint to load it from your project’s .eslintrc or eslint.config.js. Make sure the rules are documented with clear examples of what they catch and why. A rule that is not understood by the team will be ignored or disabled.
Also consider the developer experience. A rule that fires too often or produces confusing messages becomes noise. Use context.report with specific suggestions and, when possible, provide an autofix that can resolve the issue automatically. Autofixable rules are adopted far more quickly.
Turning Your Team’s Mistakes Into a Living Rulebook
The ultimate benefit of writing custom AST rules is that your linter becomes a living documentation of your team’s conventions and past mistakes. Every time an incident is traced back to a repeated pattern, you can codify that pattern as a rule. Over time, the friction of debugging the same hidden bug disappears. Your linter stops lying and starts telling the truth—because you are the one writing what it says.
Start small. Identify the one bug that has burned your team the most this year. Write a rule that catches it. Add tests, document it, and ship it to your team. Then do it again. After a few months, you will have a plugin that encodes your team’s hard-won knowledge, making the linter an even more critical part of your development workflow than the editor, the compiler, or the code review.
