refactor(core): Lint to enforce lazyloading in modules (#16843)

Co-authored-by: Juuso Tapaninen <juuso@n8n.io>
This commit is contained in:
Iván Ovejero
2025-07-01 10:45:30 +02:00
committed by GitHub
parent 4abb6a6aa1
commit 06f49c294a
9 changed files with 217 additions and 11 deletions

View File

@@ -10,6 +10,8 @@ import { NoDynamicImportTemplateRule } from './no-dynamic-import-template.js';
import { MisplacedN8nTypeormImportRule } from './misplaced-n8n-typeorm-import.js';
import { NoTypeUnsafeEventEmitterRule } from './no-type-unsafe-event-emitter.js';
import { NoUntypedConfigClassFieldRule } from './no-untyped-config-class-field.js';
import { NoTopLevelRelativeImportsInBackendModuleRule } from './no-top-level-relative-imports-in-backend-module.js';
import { NoConstructorInBackendModuleRule } from './no-constructor-in-backend-module.js';
import type { AnyRuleModule } from '@typescript-eslint/utils/ts-eslint';
export const rules = {
@@ -25,4 +27,6 @@ export const rules = {
'misplaced-n8n-typeorm-import': MisplacedN8nTypeormImportRule,
'no-type-unsafe-event-emitter': NoTypeUnsafeEventEmitterRule,
'no-untyped-config-class-field': NoUntypedConfigClassFieldRule,
'no-top-level-relative-imports-in-backend-module': NoTopLevelRelativeImportsInBackendModuleRule,
'no-constructor-in-backend-module': NoConstructorInBackendModuleRule,
} satisfies Record<string, AnyRuleModule>;

View File

@@ -0,0 +1,75 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { NoConstructorInBackendModuleRule } from './no-constructor-in-backend-module.js';
const ruleTester = new RuleTester();
ruleTester.run('no-constructor-in-backend-module', NoConstructorInBackendModuleRule, {
valid: [
{
code: `
@BackendModule({ name: 'test' })
export class TestModule {
async init() {
// initialization code
}
}`,
},
{
code: `
export class RegularClass {
constructor() {
// this is fine in regular classes
}
}`,
},
{
code: `
@SomeOtherDecorator()
export class OtherModule {
constructor() {
// this is fine with other decorators
}
}`,
},
],
invalid: [
{
code: `
@BackendModule({ name: 'test' })
export class TestModule {
constructor() {
// this should be removed
}
}`,
errors: [{ messageId: 'noConstructorInBackendModule' }],
output: `
@BackendModule({ name: 'test' })
export class TestModule {
}`,
},
{
code: `
@BackendModule({ name: 'insights' })
export class InsightsModule {
constructor(private service: SomeService) {
this.service = service;
}
async init() {
// other code
}
}`,
errors: [{ messageId: 'noConstructorInBackendModule' }],
output: `
@BackendModule({ name: 'insights' })
export class InsightsModule {
async init() {
// other code
}
}`,
},
],
});

View File

@@ -0,0 +1,41 @@
import { ESLintUtils, TSESTree } from '@typescript-eslint/utils';
export const NoConstructorInBackendModuleRule = ESLintUtils.RuleCreator.withoutDocs({
meta: {
type: 'problem',
docs: {
description:
'A class decorated with `@BackendModule` must not have a constructor. This ensures that module dependencies are loaded only when the module is used.',
},
messages: {
noConstructorInBackendModule:
'Remove the constructor from the class decorated with `@BackendModule`.',
},
fixable: 'code',
schema: [],
},
defaultOptions: [],
create(context) {
return {
'ClassDeclaration MethodDefinition[kind="constructor"]'(node: TSESTree.MethodDefinition) {
const classDeclaration = node.parent?.parent as TSESTree.ClassDeclaration;
const isBackendModule =
classDeclaration.decorators?.some(
(d) =>
d.expression.type === 'CallExpression' &&
d.expression.callee.type === 'Identifier' &&
d.expression.callee.name === 'BackendModule',
) ?? false;
if (isBackendModule) {
context.report({
node,
messageId: 'noConstructorInBackendModule',
fix: (fixer) => fixer.remove(node),
});
}
},
};
},
});

View File

@@ -0,0 +1,54 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { NoTopLevelRelativeImportsInBackendModuleRule } from './no-top-level-relative-imports-in-backend-module.js';
const ruleTester = new RuleTester();
ruleTester.run(
'no-top-level-relative-imports-in-backend-module',
NoTopLevelRelativeImportsInBackendModuleRule,
{
valid: [
{
code: `
import { Container } from '@n8n/di';
import { InstanceSettings } from 'n8n-core';
@BackendModule({ name: 'test' })
export class TestModule {
async init() {
const { LocalService } = await import('./local.service');
}
}`,
},
],
invalid: [
{
code: `
import { Container } from '@n8n/di';
import { LocalService } from './local.service';
@BackendModule({ name: 'test' })
export class TestModule {
async init() {
// code
}
}`,
errors: [{ messageId: 'placeInsideInit' }],
},
{
code: `
import { BackendModule } from '@n8n/decorators';
import { helper } from './helper';
import { config } from './config';
@BackendModule({ name: 'test' })
export class TestModule {
async init() {
// code
}
}`,
errors: [{ messageId: 'placeInsideInit' }, { messageId: 'placeInsideInit' }],
},
],
},
);

View File

@@ -0,0 +1,26 @@
import { ESLintUtils, TSESTree } from '@typescript-eslint/utils';
export const NoTopLevelRelativeImportsInBackendModuleRule = ESLintUtils.RuleCreator.withoutDocs({
meta: {
type: 'problem',
docs: {
description:
'Relative imports in `.module.ts` files must be placed inside the `init` method. This ensures that module imports are loaded only when the module is used.',
},
messages: {
placeInsideInit:
"Place this relative import inside the `init` method, using `await import('./path')` syntax.",
},
schema: [],
},
defaultOptions: [],
create(context) {
return {
'Program > ImportDeclaration'(node: TSESTree.ImportDeclaration) {
if (node.source.value.startsWith('.')) {
context.report({ node, messageId: 'placeInsideInit' });
}
},
};
},
});