diff --git a/.node-version b/.node-version index 9a2a0e2..2efc7e1 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -v20 +v20.11.1 \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 120000 index 0000000..b711bfe --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +./.node-version \ No newline at end of file diff --git a/README.md b/README.md index d09b5a7..52d8666 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,12 @@ The "recommended" preset contains the rules listed below. If you need custom con ## Rules -| Rule | Description | Recommended | +| Rule | Description | Type | | ------------------------------------------------------------------------------------ | -------------------------------------------------------------- | ----------- | -| [`@trilon/enforce-close-testing-module`](docs/rules/enforce-close-testing-module.md) | Ensures NestJS testing modules are closed properly after tests | ✅ | -| [`@trilon/check-inject-decorator`](docs/rules/check-inject-decorator.md) | Detects incorrect usage of `@Inject(TOKEN)` decorator | ✅ | -| [`@trilon/detect-circular-reference`](docs/rules/detect-circular-reference.md) | Detects usage of `forwardRef()` method | ✅ | +| [`@trilon/enforce-close-testing-module`](docs/rules/enforce-close-testing-module.md) | Ensures NestJS testing modules are closed properly after tests | Recommended ✅ | +| [`@trilon/check-inject-decorator`](docs/rules/check-inject-decorator.md) | Detects incorrect usage of `@Inject(TOKEN)` decorator | Recommended ✅ | +| [`@trilon/detect-circular-reference`](docs/rules/detect-circular-reference.md) | Detects usage of `forwardRef()` method | Recommended ✅ | +| [`@trilon/enforce-custom-provider-type`](docs/rules/enforce-custom-provider-type.md) | Enforces a styleguide for provider types | Strict ⚠️ | --- # Trilon Consulting diff --git a/docs/rules/enforce-custom-provider-type.md b/docs/rules/enforce-custom-provider-type.md new file mode 100644 index 0000000..82213c7 --- /dev/null +++ b/docs/rules/enforce-custom-provider-type.md @@ -0,0 +1,55 @@ +--- +description: 'Enforces a styleguide for provider types' +--- + +Large teams can have the desire to limit or enforce a particular style of creating [custom providers](https://docs.nestjs.com/fundamentals/custom-providers); e.g. banning request-scoped providers to avoid potential circular dependencies, or [preferring factory providers over value providers to significantly increase performance](https://github.com/nestjs/nest/pull/12753). This rule enforces a particular type of provider to be used. + +## Options + +This rule accepts an object with the "prefer" property, which is an array containing one or more of the following values: + +- `value`: Enforces the use of value providers. +- `factory`: Enforces the use of factory providers. +- `class`: Enforces the use of class providers. +- `existing`: Enforces the use of existing providers. + + +### Example of Options + +```json +"rules": { + "@trilon/enforce-custom-provider-type": [ + "warn", { + "prefer": ["factory", "value"] + } + ] +} +``` + +## Examples +Considering the options above, the following examples will show how the rule behaves when the `prefer` option is set to `factory`. + +### ❌ Incorrect + +```ts +const customValueProvider: Provider = { + provide: 'TOKEN', + useExisting: 'some-value' // ⚠️ provider is not of type ["factory", "value"] +} + +const customClassProvider: Provider = { + provide: AbstractClass, + useClass: SomeClass // ⚠️ provider is not of type ["factory", "value"] +} +``` + +### ✅ Correct + +const factoryProvider: Provider = { + provide: 'TOKEN', + useFactory: () => 'some-value' +} + +## When Not To Use It + +If you don't want to enforce a particular style of provider, you can disable this rule. diff --git a/package.json b/package.json index e4bd7ec..cc7a17a 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "LICENSE" ], "engines": { - "node": ">=20.9.0", + "node": ">=18.*.*", "yarn": ">=4.0.2" }, "scripts": { diff --git a/src/index.ts b/src/index.ts index fe8ef49..b9637bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import enforceCloseTestingModuleRule from './rules/enforce-close-testing-module.rule'; import checkInjectDecoratorRule from './rules/check-inject-decorator.rule'; import detectCircularReferenceRule from './rules/detect-circular-reference.rule'; +import enforceCustomProviderTypeRule from './rules/enforce-custom-provider-type.rule'; // TODO: we should type this as ESLint.Plugin but there's a type incompatibilities with the utils package const plugin = { configs: { @@ -11,11 +12,20 @@ const plugin = { '@trilon/detect-circular-reference': 'warn', }, }, + strict: { + rules: { + '@trilon/enforce-close-testing-module': 'error', + '@trilon/check-inject-decorator': 'error', + '@trilon/detect-circular-reference': 'error', + '@trilon/enforce-custom-provider-type': 'error', + }, + }, }, rules: { 'enforce-close-testing-module': enforceCloseTestingModuleRule, 'check-inject-decorator': checkInjectDecoratorRule, 'detect-circular-reference': detectCircularReferenceRule, + '@trilon/enforce-custom-provider-type': enforceCustomProviderTypeRule, }, }; diff --git a/src/rules/enforce-custom-provider-type.rule.ts b/src/rules/enforce-custom-provider-type.rule.ts new file mode 100644 index 0000000..5b0bc59 --- /dev/null +++ b/src/rules/enforce-custom-provider-type.rule.ts @@ -0,0 +1,172 @@ +import { + ASTUtils, + AST_NODE_TYPES, + ESLintUtils, + type TSESTree, +} from '@typescript-eslint/utils'; + +const createRule = ESLintUtils.RuleCreator( + (name) => `https://eslint.org/docs/latest/rules/${name}` +); + +type ProviderType = 'class' | 'factory' | 'value' | 'existing' | 'unknown'; + +export type Options = [ + { + prefer: ProviderType[]; + }, +]; + +const defaultOptions: Options = [ + { + prefer: [], + }, +]; + +export type MessageIds = 'providerTypeMismatch'; + +export default createRule({ + name: 'enforce-custom-provider-type', + meta: { + type: 'suggestion', + docs: { + description: 'Ensure that custom providers are of the preferred type', + }, + fixable: undefined, + schema: [ + { + type: 'object', + properties: { + prefer: { + type: 'array', + items: { + type: 'string', + enum: ['class', 'factory', 'value', 'existing'], + }, + }, + }, + }, + ], + messages: { + providerTypeMismatch: 'Provider is not of type {{ preferred }}', + }, + }, + defaultOptions, + create(context) { + const options = context.options[0] || defaultOptions[0]; + const preferredTypes = options.prefer; + const providerTypesImported: string[] = []; + return { + 'ImportDeclaration[source.value="@nestjs/common"]': ( + node: TSESTree.ImportDeclaration + ) => { + const specifiers = node.specifiers; + + const isImportSpecifier = ( + node: TSESTree.ImportClause + ): node is TSESTree.ImportSpecifier => + node.type === AST_NODE_TYPES.ImportSpecifier; + + const isProviderImport = (spec: TSESTree.ImportSpecifier) => + [ + 'Provider', + 'ClassProvider', + 'FactoryProvider', + 'ValueProvider', + ].includes(spec.imported.name); + + specifiers + .filter(isImportSpecifier) + .filter(isProviderImport) + .forEach((spec) => + providerTypesImported.push(spec.local.name ?? spec.imported.name) + ); + }, + + 'Property[key.name="providers"] > ArrayExpression > ObjectExpression': ( + node: TSESTree.ObjectExpression + ) => { + for (const property of node.properties) { + if (property.type === AST_NODE_TYPES.Property) { + const providerType = providerTypeOfProperty(property); + + if ( + providerType && + !preferredTypes.includes(providerType) && + preferredTypes.length > 0 + ) { + context.report({ + node: property, + messageId: 'providerTypeMismatch', + data: { + preferred: preferredTypes, + }, + }); + } + } + } + }, + + 'Identifier[typeAnnotation.typeAnnotation.type="TSTypeReference"]': ( + node: TSESTree.Identifier + ) => { + const typeName = ( + node.typeAnnotation?.typeAnnotation as TSESTree.TSTypeReference + ).typeName; + + if ( + ASTUtils.isIdentifier(typeName) && + providerTypesImported.includes(typeName.name) && + preferredTypes.length > 0 + ) { + const providerType = providerTypeOfIdentifier(node); + if (providerType && !preferredTypes.includes(providerType)) { + context.report({ + node, + messageId: 'providerTypeMismatch', + data: { + preferred: preferredTypes, + }, + }); + } + } + }, + }; + }, +}); + +function providerTypeOfIdentifier( + node: TSESTree.Identifier +): ProviderType | undefined { + const parent = node.parent; + + if (ASTUtils.isVariableDeclarator(parent)) { + const init = parent.init; + let type: ProviderType | undefined; + if (init?.type === AST_NODE_TYPES.ObjectExpression) { + const properties = init.properties; + for (const property of properties) { + if (property.type === AST_NODE_TYPES.Property) { + type = providerTypeOfProperty(property); + } + } + } + + return type; + } +} + +function providerTypeOfProperty( + node: TSESTree.Property +): ProviderType | undefined { + const propertyKey = (node.key as TSESTree.Identifier)?.name; + return propertyKey === 'useClass' + ? 'class' + : propertyKey === 'useFactory' + ? 'factory' + : propertyKey === 'useValue' + ? 'value' + : propertyKey === 'useExisting' + ? 'existing' + : undefined; +}; \ No newline at end of file diff --git a/tests/rules/enforce-custom-provider-type.rule.spec.ts b/tests/rules/enforce-custom-provider-type.rule.spec.ts new file mode 100644 index 0000000..8dbc4cc --- /dev/null +++ b/tests/rules/enforce-custom-provider-type.rule.spec.ts @@ -0,0 +1,310 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; +import enforceCustomProviderTypeRule from '../../src/rules/enforce-custom-provider-type.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('enforce-custom-provider-type', enforceCustomProviderTypeRule, { + valid: [ + // Test for when no options are provided + { + code: ` + import { Provider } from '@nestjs/common'; + + const factoryProvider: Provider = { + provide: 'TOKEN', + useFactory: () => 'some-value' + } + `, + }, + // Test for when the provider is of the preferred type + { + code: ` + import { Provider } from '@nestjs/common'; + + const factoryProvider: Provider = { + provide: 'TOKEN', + useFactory: () => 'some-value' + } + `, + options: [ + { + prefer: ['factory'], + }, + ], + }, + // Test for when the provider is one of the preferred types + { + code: ` + import { Provider } from '@nestjs/common'; + import { SomeClass } from './some-class'; + + const factoryProvider: Provider = { + provide: 'TOKEN', + useClass: SomeClass + } + `, + options: [ + { + prefer: ['factory', 'class'], + }, + ], + }, + // Test for when the Provider type is not imported from '@nestjs/common' + { + code: ` + import { Provider } from 'some-other-package'; + const customValueProvider: Provider = { + useValue: 'some-value', + provide: 'TOKEN' + } + `, + options: [ + { + prefer: ['factory'], + }, + ], + }, + ], + invalid: [ + // Test for when the provider is not of the preferred type + { + code: ` + import { Provider } from '@nestjs/common'; + const customValueProvider: Provider = { + provide: 'TOKEN', + useValue: 'some-value' // ⚠️ provider is not of type "factory" + } + `, + options: [ + { + prefer: ['factory'], + }, + ], + errors: [ + { + messageId: 'providerTypeMismatch', + }, + ], + }, + // Test for when the provider is not one the preferred types + { + code: ` + import { Provider } from '@nestjs/common'; + const customValueProvider: Provider = { + provide: 'TOKEN', + useValue: 'some-value' // ⚠️ provider is not of type "factory" + } + `, + options: [ + { + prefer: ['factory', 'class', 'existing'], + }, + ], + errors: [ + { + messageId: 'providerTypeMismatch', + }, + ], + }, + { + code: ` + import { Provider } from '@nestjs/common'; + import { SomeClass } from './some-class'; + const customValueProvider: Provider = { + provide: 'TOKEN', + useClass: SomeClass // ⚠️ provider is not of type "factory" + } + `, + options: [ + { + prefer: ['factory'], + }, + ], + errors: [ + { + messageId: 'providerTypeMismatch', + }, + ], + }, + { + code: ` + import { Provider } from '@nestjs/common'; + import { EXISTING_TOKEN } from './token'; + const customValueProvider: Provider = { + provide: 'TOKEN', + useExisting: EXISTING_TOKEN // ⚠️ provider is not of type "factory" + } + `, + options: [ + { + prefer: ['factory'], + }, + ], + errors: [ + { + messageId: 'providerTypeMismatch', + }, + ], + }, + // Test for when the provider is not of the preferred type and was renamed + { + code: ` + import { Provider as NestProvider } from '@nestjs/common'; + import { EXISTING_TOKEN } from './token'; + const customValueProvider: NestProvider = { + provide: 'TOKEN', + useExisting: EXISTING_TOKEN // ⚠️ provider is not of type "factory" + } + `, + options: [ + { + prefer: ['factory'], + }, + ], + errors: [ + { + messageId: 'providerTypeMismatch', + }, + ], + }, + + // Test for when the provider (value) is not of the preferred type (factory) in the "providers" array + { + code: ` + import { Module } from '@nestjs/common'; + @Module({ + providers: [ + { + provide: 'TOKEN', + useValue: 'value-in-providers-array', + } + ] + }) + export class SomeModule {} + `, + errors: [ + { + messageId: 'providerTypeMismatch', + }, + ], + options: [ + { + prefer: ['factory'], + }, + ], + }, + // Test for when the provider (class) is not of the preferred type (factory) in the "providers" array + { + code: ` + import { Module } from '@nestjs/common'; + import { SomeClass } from './some-class'; + + @Module({ + providers: [ + { + provide: 'TOKEN', + useClass: SomeClass, + } + ] + }) + export class SomeModule {} + `, + errors: [ + { + messageId: 'providerTypeMismatch', + }, + ], + options: [ + { + prefer: ['factory'], + }, + ], + }, + // Test for when the provider (existing) is not of the preferred type (factory) in the "providers" array + { + code: ` + import { Module } from '@nestjs/common'; + import { SomeClass } from './some-class'; + + @Module({ + providers: [ + { + provide: 'TOKEN', + useExisting: SomeClass, + } + ] + }) + export class SomeModule {} + `, + errors: [ + { + messageId: 'providerTypeMismatch', + }, + ], + options: [ + { + prefer: ['factory'], + }, + ], + }, + // Test for when many provider types are not of the preferred type (factory) in the "providers" array + { + code: ` + import { Module } from '@nestjs/common'; + import { SomeClass } from './some-class'; + + @Module({ + providers: [ + { + provide: 'TOKEN', + useExisting: SomeClass, + }, + { + provide: 'TOKEN', + useClass: SomeClass, + }, + { + provide: 'TOKEN', + useValue: 'value-in-providers-array', + }, + { + provide: 'TOKEN', + useFactory: () => 'value-in-providers-array', + } + ] + }) + export class SomeModule {} + `, + errors: [ + { + messageId: 'providerTypeMismatch', + }, + { + messageId: 'providerTypeMismatch', + }, + { + messageId: 'providerTypeMismatch', + }, + ], + options: [ + { + prefer: ['factory'], + }, + ], + }, + ], +});