Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Provide an ESLint rule that helps with catching injection typos #388

Open
MathiasWP opened this issue Jan 29, 2025 · 6 comments
Open

Provide an ESLint rule that helps with catching injection typos #388

MathiasWP opened this issue Jan 29, 2025 · 6 comments

Comments

@MathiasWP
Copy link
Contributor

It's quite easy to have a typo when injecting with Awilix. It would make a lot of sense for Awilix to provide an ESLint rule that helps users catch mistakes where the provided interface does not match the name of the parameter in the construction, both in classic and proxy mode.

@jeffijoe
Copy link
Owner

Are you talking about type checking that the registration's type matches the parameter's type? Or are you talking about making sure there's a registration for the parameter name?

Either way, both are going to be tricky considering loadModules use making it difficult to statically analyze usages. Not to mention library-specific bindings like controllers from awilix-koa and awilix-express.

Would be cool though!

@MathiasWP
Copy link
Contributor Author

MathiasWP commented Jan 29, 2025

Are you talking about type checking that the registration's type matches the parameter's type? Or are you talking about making sure there's a registration for the parameter name?

Either way, both are going to be tricky considering loadModules use making it difficult to statically analyze usages. Not to mention library-specific bindings like controllers from awilix-koa and awilix-express.

Would be cool though!

In our case we inject dependencies like this:

export default MyService {
		constructor({
			authenticationService,
			userService
		}: {
			authenticationService: IAuthenticationService,
			userService: IUserService
		})
}

And we just found a bug where we had incorrectly written

export default MyService {
		constructor({
			authorizationService,
			userService
		}: {
			// Typo here, should be authenticationService
			authorizationService: IAuthenticationService,
			userService: IUserService
		})
}

So having a lint rule that catches these mistakes when writing the code would be really great. You can specify with ESLint what files the rules should run on, so it would be easy to configure it to run on the correct files.

We have written a proof-of-concept ESLint rule that catches these kind of mistakes in proxy mode. But having a supported official package from Awilix would be great.

It makes a lot of sense for Awilix to provide this, as these are bugs you cannot catch with the TS compiler at all, and it would improve the DX of this library a lot 😸

Classic mode

It should also work for this i suppose. You could have to separate rules, one for classic and one for proxy.

export default MyService {
		// Typo here, should be authenticationService
		constructor(authorizationService: IAuthenticationService, userService: IUserService)
}

@MathiasWP
Copy link
Contributor Author

Here's the code for the current ESLint rule we've written:

import { TSESTree } from '@typescript-eslint/types';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
import type { Rule } from 'eslint';
import path from 'path';

const rule: Rule.RuleModule = {
	meta: {
		type: 'problem',
		docs: {
			description: 'Enforce constructor parameter names to match their type names'
		},
		schema: [
			{
				type: 'object',
				properties: {
					includePaths: {
						description: 'List all paths that should be checked. If empty, all paths are checked',
						type: 'array',
						items: { type: 'string' }
					}
				},
				additionalProperties: false
			}
		],
		messages: {
			mismatchedParamName:
				'Parameter `{{ paramName }}` should be `{{ expectedParamName }}` for type "{{ typeName }}"'
		}
	},
	create(context) {
		const options = context.options[0] || {};
		const includePaths: string[] = options.includePaths || [];

		// Get the current file path relative to the project root
		const filename = context.filename;
		const relativePath = path.relative(process.cwd(), filename);

		// If includePaths is empty, apply to all files
		// Otherwise, check if the file is in one of the included paths
		const shouldApplyRule =
			includePaths.length === 0 ||
			includePaths.some((includePath) => relativePath.includes(includePath));

		// If the file should not be checked, return empty object to skip rule
		if (!shouldApplyRule) {
			return {};
		}

		return {
			ClassDeclaration(node) {
				// Ignore abstract classes
				//@ts-expect-error: This exists even though the linter does not think so
				const isAbstract: boolean | undefined = node.abstract;
				if (isAbstract === true) return;

				// Find the constructor
				const constructor = node.body.body.find(
					(member) =>
						member.type === AST_NODE_TYPES.MethodDefinition && member.kind === 'constructor'
				);
				if (!constructor) return;

				const params = (constructor as any).value.params;

				// Get the (first) object param inside the constructor
				const objectParam: TSESTree.ObjectPattern = params.find(
					(param: any) => param.type === AST_NODE_TYPES.ObjectPattern
				);
				if (!objectParam) return;

				//@ts-expect-error: Linter does not thing members are available here...
				objectParam.typeAnnotation?.typeAnnotation?.members.forEach((member: any) => {
					const typeName: string | undefined =
						member?.typeAnnotation?.typeAnnotation?.typeName?.name;
					const paramName: string | undefined = member?.key?.name;
					if (!typeName || !paramName) return;

					const expectedParamName = typeName.charAt(0).toLowerCase() + typeName.slice(1);
					// Allows for both {myType: IMyType and MyType} to be valid
					const typeNameWithoutI = typeName.startsWith('I') ? typeName.slice(1) : typeName;
					const expectedParamNameWithoutI =
						typeNameWithoutI.charAt(0).toLowerCase() + typeNameWithoutI.slice(1);

					if (paramName !== expectedParamNameWithoutI && paramName !== expectedParamName) {
						context.report({
							node: member,
							messageId: 'mismatchedParamName',
							data: {
								paramName,
								typeName: typeName,
								expectedParamName: expectedParamNameWithoutI
							}
						});
					}
				});
			}
		};
	}
};

export default rule;

Note that this isn't perfect, but we had to get something working for our case asap.

It throws a lint error that looks like this, greatly improving our DX (note the typo in areasOfResponsibilityCacheServicee with an extra "e" at the end:

Image

@jeffijoe
Copy link
Owner

jeffijoe commented Jan 29, 2025

Ah, I see! So you're doing this purely based on the type annotation's name? What about if you used the wrong type, or you name your registrations differently? My concern with doing an "official" plugin is that it'll be expected to catch every misuse under the sun. 😅

@MathiasWP
Copy link
Contributor Author

Ah, I see! So you're doing this purely based on the type annotation's name? What about if you used the wrong type, or you name your registrations differently? My concern with doing an "official" plugin is that it'll be expected to catch every misuse under the sun. 😅

I totally get that, i can imagine there's a bunch of different cases here, and that this lint wish is only valid for TS.

But could it be possible to have different configurations for the rule? So that you can configure it to match your code setup?

@jeffijoe
Copy link
Owner

Perhaps! It seems like you've got something going, if you create a repo and publish it, I can add it to the ecosystem section in the README if you want?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

2 participants