diff --git a/docs/rules/check-inject-decorator.md b/docs/rules/check-inject-decorator.md new file mode 100644 index 0000000..8ea1116 --- /dev/null +++ b/docs/rules/check-inject-decorator.md @@ -0,0 +1,47 @@ +--- +description: 'Detects incorrect usage of `@Inject(TOKEN)` decorator' +--- + +@Inject() decorators are useful for injecting [custom providers](https://docs.nestjs.com/fundamentals/custom-providers) into a class. However, it is easy to misuse the decorator by passing a token that does not match the type of the property - or by passing a token that duplicates the type of the property. This rule detects these cases. + +## Options + +This rule has no additional options yet. + + +## Examples + +### ❌ Incorrect + +```ts +class FooBarController { + constructor( + // 🚫 Type is an interface and cannot be injected + private readonly barService: IService, + + @Inject(BazService) // ⚠️ Token duplicates type + private readonly bazService: BazService, + ) {} +} +``` + +### ✅ Correct + +```ts +class FooBarController { + @Inject(FooService) // ✅ Token duplicates type, but class properties don't have type metadata + private readonly fooService: FooService; + + constructor( + @Inject('BAR_SERVICE') // ✅ Token differs from type + private readonly barService: IService, + + // ✅ Type metadata is sufficient to inject + private readonly bazService: BazService, + ) {} +} +``` + +## When Not To Use It + +If you want to manage injection tokens manually, you can disable this rule. diff --git a/docs/rules/enforce-close-testing-module.md b/docs/rules/enforce-close-testing-module.md index 7131466..8145d86 100644 --- a/docs/rules/enforce-close-testing-module.md +++ b/docs/rules/enforce-close-testing-module.md @@ -15,15 +15,16 @@ This rule accepts an object with two properties: `createAliases` and `closeAlias ```json { - "nestjs/close-testing-modules": ["error", { - "createAliases": [ - { "kind": "function", "name": "customCreateTestingModule" }, - { "kind": "method", "name": "alternativeCreateMethod" } - ], - "closeAliases": [ - { "kind": "method", "name": "customCloseMethod" } - ] - }] + "nestjs/close-testing-modules": [ + "error", + { + "createAliases": [ + { "kind": "function", "name": "customCreateTestingModule" }, + { "kind": "method", "name": "alternativeCreateMethod" } + ], + "closeAliases": [{ "kind": "method", "name": "customCloseMethod" }] + } + ] } ``` @@ -106,7 +107,7 @@ describe('Creates and closes the appModule using custom functions', () => { let app: INestApplication; beforeEach(async () => { // defined via the "createAliases" option as { kind: 'function', name: 'createTestingModule' } - const testingModule = await createTestingModule(); + const testingModule = await createTestingModule(); app = testingModule.createNestApplication(); }); @@ -116,7 +117,7 @@ describe('Creates and closes the appModule using custom functions', () => { afterEach(async () => { // defined via the "closeAliases" option as { kind: 'function', name: 'closeTestingModule' } - await closeTestingModule(testingModule); + await closeTestingModule(testingModule); }); }); @@ -124,7 +125,7 @@ describe('Creates and closes the appModule using custom methods', () => { let app: INestApplication; beforeEach(async () => { // defined via the "createAliases" option as { kind: 'method', name: 'createTestingModule' } - const testingModule = await testUtils.createTestingModule(); + const testingModule = await testUtils.createTestingModule(); app = testingModule.createNestApplication(); }); @@ -134,7 +135,7 @@ describe('Creates and closes the appModule using custom methods', () => { afterEach(async () => { // defined via the "closeAliases" option as { kind: 'method', name: 'close' } - await testUtils.close(testingModule); + await testUtils.close(testingModule); }); }); ``` diff --git a/src/ast-traverser.util.ts b/src/ast-traverser.util.ts index 8b1747c..80fa1f8 100644 --- a/src/ast-traverser.util.ts +++ b/src/ast-traverser.util.ts @@ -1,5 +1,5 @@ import type { TSESTree } from '@typescript-eslint/utils'; -import { AST_NODE_TYPES } from '@typescript-eslint/utils'; +import { ASTUtils, AST_NODE_TYPES } from '@typescript-eslint/utils'; export function getAllParentNodesOfType( node: TSESTree.Node, @@ -55,3 +55,22 @@ export function firstAssignmentExpressionInParentChain( AST_NODE_TYPES.AssignmentExpression ); } + +export function getInjectDecoratorFor(node: TSESTree.Node) { + return (node as TSESTree.TSParameterProperty)?.decorators?.find( + (decorator) => + decorator.expression.type === AST_NODE_TYPES.CallExpression && + ASTUtils.isIdentifier(decorator.expression.callee) && + decorator.expression.callee.name === 'Inject' + ); +} + +export function getInjectedTokenFor(decoratorNode?: TSESTree.Decorator) { + const injectedIdentifier = ( + decoratorNode?.expression as TSESTree.CallExpression + )?.arguments[0]; + + const injectedToken = (injectedIdentifier as TSESTree.Identifier)?.name; + + return injectedToken; +} diff --git a/src/index.ts b/src/index.ts index e17f84e..f1987b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,18 @@ import enforceCloseTestingModuleRule from './rules/enforce-close-testing-module.rule'; - +import checkInjectDecoratorRule from './rules/check-inject-decorator.rule'; // TODO: we should type this as ESLint.Plugin but there's a type incompatibilities with the utils package const plugin = { configs: { recommended: { - rules: { '@trilon/enforce-close-testing-module': 'error' }, + rules: { + '@trilon/enforce-close-testing-module': 'error', + '@trilon/check-inject-decorator': 'error', + }, }, }, rules: { 'enforce-close-testing-module': enforceCloseTestingModuleRule, + 'check-inject-decorator': checkInjectDecoratorRule, }, }; diff --git a/src/rules/check-inject-decorator.rule.ts b/src/rules/check-inject-decorator.rule.ts new file mode 100644 index 0000000..085a630 --- /dev/null +++ b/src/rules/check-inject-decorator.rule.ts @@ -0,0 +1,68 @@ +import { ASTUtils, ESLintUtils, type TSESTree } from '@typescript-eslint/utils'; +import * as traverser from '../ast-traverser.util'; + +const createRule = ESLintUtils.RuleCreator( + (name) => `https://eslint.org/docs/latest/rules/${name}` +); + +export type MessageIds = + | 'tokenDuplicatesType' + | 'typeIsInterface' + | 'propertyMissingInject'; + +const defaultOptions: unknown[] = []; + +export default createRule({ + name: 'check-inject-decorator', + meta: { + type: 'problem', + docs: { + description: 'Ensure proper use of the @Inject decorator', + recommended: 'recommended', + }, + fixable: undefined, + schema: [], // no options + messages: { + tokenDuplicatesType: '⚠️ Token duplicates type', + typeIsInterface: '⚠️ Type is an interface and cannot be injected', + propertyMissingInject: '⚠️ Did you want to `@Inject({{type}})`?', + }, + }, + defaultOptions, + create(context) { + return { + // Matches: constructor(@Inject(FOO_SERVICE) private readonly >>fooService<<: FooService) + 'TSParameterProperty > Identifier[typeAnnotation.typeAnnotation.type="TSTypeReference"]': + (node: TSESTree.Identifier) => { + const typeName = ( + node.typeAnnotation?.typeAnnotation as TSESTree.TSTypeReference + ).typeName; + + const injectDecorator = traverser.getInjectDecoratorFor(node.parent); + const injectedToken = traverser.getInjectedTokenFor(injectDecorator); + + if ( + ASTUtils.isIdentifier(typeName) && + typeName.name === injectedToken + ) { + context.report({ + node, + messageId: 'tokenDuplicatesType', + loc: node.loc, + }); + } + + const services = ESLintUtils.getParserServices(context); + const type = services.getTypeAtLocation(node); + + if (!type.isClass() && type.isClassOrInterface() && !injectedToken) { + context.report({ + node, + messageId: 'typeIsInterface', + loc: node.loc, + }); + } + }, + }; + }, +}); diff --git a/src/rules/enforce-close-testing-module.rule.ts b/src/rules/enforce-close-testing-module.rule.ts index 9b8cd90..32e03ca 100644 --- a/src/rules/enforce-close-testing-module.rule.ts +++ b/src/rules/enforce-close-testing-module.rule.ts @@ -329,4 +329,4 @@ function beforeHookContainingNode( result = callExpressionWithHook.callee.name as TestBeforeHooks; } return result; -} \ No newline at end of file +} diff --git a/tests/rules/check-inject-decorator.spec.ts b/tests/rules/check-inject-decorator.spec.ts new file mode 100644 index 0000000..b590d78 --- /dev/null +++ b/tests/rules/check-inject-decorator.spec.ts @@ -0,0 +1,117 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; +import checkInjectDecorator from '../../src/rules/check-inject-decorator.rule'; + +// This test required changes to the tsconfig file to allow importing from the rule-tester package. +// See https://github.com/typescript-eslint/typescript-eslint/issues/7284 + +const ruleTester = new RuleTester({ + parserOptions: { + project: './tsconfig.json', + }, + parser: '@typescript-eslint/parser', + defaultFilenames: { + // We need to specify a filename that will be used by the rule parser. + // Since the test process starts at the root of the project, we need to point to the sub folder containing it. + ts: './tests/rules/file.ts', + tsx: '', + }, +}); + +ruleTester.run('check-inject-decorator', checkInjectDecorator, { + valid: [ + { + code: ` + const FOO_SERVICE = Symbol('FOO_SERVICE'); + class FooBarController { + constructor( + @Inject(FOO_SERVICE) + private readonly fooService: FooService, + ) {} + } + `, + }, + { + code: ` + class FooClass { + foo: string; + } + class FooBarController { + constructor( + private readonly fooService: FooClass, + ) {} + } + `, + }, + { + code: ` + class FooService { + @Inject(Reflector) + private readonly reflector: Reflector; + } + `, + }, + { + code: ` + import { INTERFACE_TOKEN } from './foo.interface.provider'; + interface FooInterface { } + class FooService { + constructor( + @Inject(INTERFACE_TOKEN) + private readonly secondInterface: FooInterface + ) {} + } + `, + }, + ], + invalid: [ + { + code: ` + class FooBarController { + constructor( + @Inject(BazService) // ⚠️ Token duplicates type + private readonly bazService: BazService, + ) {} + } + `, + errors: [ + { + messageId: 'tokenDuplicatesType', + }, + ], + }, + { + code: ` + interface FooInterface { + foo: string; + } + class FooBarController { + constructor( + private readonly fooService: FooInterface, + ) {} + } + `, + errors: [ + { + messageId: 'typeIsInterface', + }, + ], + }, + { + code: ` + interface FooInterface { }; + class BarService { + @Inject(FOO_SERVICE) + private readonly fooService: FooInterface + constructor( + private readonly fooService2: FooInterface, + ) {} + } + `, + errors: [ + { + messageId: 'typeIsInterface', + }, + ], + }, + ], +});