Our team kept reintroducing the same bug: importing directly from a service's internal module path instead of its public index file, which worked fine until an internal refactor moved something and broke every import that had reached past the intended public surface. Writing it up in a docs page changed nothing, because nobody reads the docs page mid-refactor at 4pm on a Friday. Writing a custom ESLint rule that catches it automatically is what actually stopped it, and building one from scratch was more approachable than I expected once I understood the AST-based mental model.
An ESLint rule doesn't pattern-match against source code as a string, it walks the Abstract Syntax Tree that ESLint's parser has already built, and registers visitor functions for specific node types, ImportDeclaration, CallExpression, whatever the rule cares about. Understanding this upfront saved me from trying to write regex against source code, which is fragile and can't reason about actual code structure, versus a proper AST visitor which sees the real, unambiguous parsed structure of an import statement regardless of formatting quirks.
A rule is a plain object with a meta block describing itself and a create function returning an object of AST node-type visitors. For catching a deep import into a service's internals, the relevant node type is ImportDeclaration, and the check is on the source value's path shape.
// eslint-rules/no-deep-service-imports.js
module.exports = {
meta: {
type: 'problem',
docs: { description: 'Disallow importing service internals directly' },
schema: [],
messages: {
deepImport:
"Import from '{{service}}' instead of reaching into its internal path.",
},
},
create(context) {
return {
ImportDeclaration(node) {
const source = node.source.value;
const match = source.match(/^@services\/(\w+)\/internal\//);
if (match) {
context.report({
node,
messageId: 'deepImport',
data: { service: match[1] },
});
}
},
};
},
};
ESLint ships a RuleTester specifically for this, taking arrays of valid code snippets that should produce no errors and invalid snippets that should produce a specific error, which caught an edge case I hadn't considered: a dynamic import(), a different AST node type entirely (ImportExpression, not ImportDeclaration), that my first version silently let through because I'd only registered a visitor for the static form.
const { RuleTester } = require('eslint');
const rule = require('./no-deep-service-imports');
const ruleTester = new RuleTester({
languageOptions: { ecmaVersion: 2022, sourceType: 'module' },
});
ruleTester.run('no-deep-service-imports', rule, {
valid: [
`import { billing } from '@services/billing';`,
],
invalid: [
{
code: `import { chargeCard } from '@services/billing/internal/charge';`,
errors: [{ messageId: 'deepImport' }],
},
],
});
Rules can offer an automatic fix via a fix function returning a set of text edits, which ESLint applies when run with --fix. For this rule, auto-fixing isn't safe, the tool has no way to know what the correct public export name actually is on the target service's index file, so I deliberately left fixable false and relied on the message pointing the developer at the right import instead. Forcing an auto-fix here would risk silently rewriting code to something that doesn't actually work, which is worse than requiring a manual fix.
ESLint 9's flat config represents plugins as plain JavaScript objects rather than string-based lookups, which made registering a genuinely local, unpublished rule far more direct than the old .eslintrc system's plugin resolution ever was, no fake npm package name needed just to satisfy the old string-based plugin loader.
// eslint.config.js
const noDeepServiceImports = require('./eslint-rules/no-deep-service-imports');
module.exports = [
{
plugins: {
local: { rules: { 'no-deep-service-imports': noDeepServiceImports } },
},
rules: {
'local/no-deep-service-imports': 'error',
},
},
];
Before writing the visitor logic, pasting a sample of the exact code pattern I wanted to catch into an online AST explorer and inspecting the actual tree structure it produced saved real time versus guessing at property names from memory. The node.source.value path for an import's module specifier isn't something worth memorizing, it's worth confirming against the real tree every time you're writing a new rule, since getting a property name wrong just makes the visitor silently never fire rather than throwing an obvious error.
Turning the rule on as an error immediately would have failed CI on every existing violation across the codebase at once. Starting it as a warn severity for two weeks, tracking the warning count trend, then flipping it to error once the count hit zero, got the team time to clean up existing violations gradually instead of a single disruptive red-CI day that would have just gotten the rule disabled out of frustration.
A custom ESLint rule is a small, learnable amount of AST-visitor code once you stop thinking of source code as text and start thinking of it as a tree, and it converts a convention that lived only in a docs page nobody read into something CI enforces automatically on every pull request. For any team-specific pattern that keeps recurring despite being "documented," this is worth the afternoon it takes, the docs page clearly wasn't working on its own.