Repository: sindresorhus/eslint-plugin-unicorn (JavaScript)

Issue:
The `prefer-add-event-listener` rule should not apply to Koa's `app.context.onerror`
<!-- Issuehunt Badges -->

[<img alt="Issuehunt badges" src="https://issuehunt.io/r/sindresorhus/eslint-plugin-unicorn/issues/174/badge.svg" />](https://issuehunt.io/r/sindresorhus/eslint-plugin-unicorn/issues/174)

<!-- /Issuehunt Badges -->


<!-- Issuehunt content -->



> [medusalix](https://issuehunt.io/u/medusalix) earned $40.00 by resolving this issue!
> - Checkout the [Issuehunt explorer](https://issuehunt.io/r/sindresorhus/eslint-plugin-unicorn/) to discover more funded issues.
> - Need some help from other developers? [Add your repositories](https://issuehunt.io/r/new) on Issuehunt to raise funds.


<!-- /Issuehunt content-->


Discussion:
The only workaround I can think of is to detect `koa` being imported and not report.

It's easy to think we can just detect `app.context.onerror`, but it can be written in so many ways with variables in between and it's just too complicated with the limited things we can detect and do with ESLint.
`sax` also has an `.onerror` API, and would need to be excluded from this rule.

https://github.com/isaacs/sax-js
@issuehuntfest has funded $40.00 to this issue. [See it on IssueHunt](https://issuehunt.io/repos/55832243/issues/174)
I would like to work on this issue. If I understood correctly, `koa` and `sax` should be excluded from this rule, right?
@medusalix 	those are the two that are most urgent right now, thank you
I thought of doing it like this:

```js
Program() {
    disabled = false;
},

'CallExpression[callee.name="require"]': node => {
    // If 'package' ends with 'koa' or 'sax' => disabled = true
}

// Disable other selectors based on 'disabled' value
```

Although I already got it working this way, I wanted to ask if some of you would know a better approach to this problem? It's pretty simple, so there might be some edge cases where it's going to fail.
@medusalix Yeah, that's what I would do too. It also needs to handle `import` statement.

> If 'package' ends with 'koa' or 'sax'

Did you means `ends` => `matches`?

---

And `disabled` => `isDisabled`.
@sindresorhus I'm actually checking the package name via `endsWith`. Consider the following case:

```js
const sax = require('./lib/sax');
```

Maybe we should split the path on each `/` and check whether the last part equals `sax`?
When using `endsWith`, something like this would still pass 😕

```js
const sax = require('./lib/somethingnotrelatedtosax');
```

Do you think a regex would be a better choice?
It should not handle relative paths. Only npm packages (`import koa from 'koa';`). So we'll just have a `Set` of npm package imports that disables the rule. Like: `new Set(['koa', 'sax'])`.
Ok, that makes sense.
@sindresorhus has rewarded $36.00 to @medusalix. [See it on IssueHunt](https://issuehunt.io/repos/55832243/issues/174)

- :moneybag: Total deposit: $40.00
- :tada: Repository reward(0%): $0.00
- :wrench: Service fee(10%): $4.00



Patch:
diff --git a/docs/rules/prefer-add-event-listener.md b/docs/rules/prefer-add-event-listener.md
index c13f8de294..4444a66292 100644
--- a/docs/rules/prefer-add-event-listener.md
+++ b/docs/rules/prefer-add-event-listener.md
@@ -40,3 +40,23 @@ foo.bar.addEventListener('click', onClick);
 ```js
 foo.removeEventListener('click', onClick);
 ```
+
+
+## Options
+
+### excludedPackages
+
+```js
+"unicorn/prefer-add-event-listener": ["error", {"excludedPackages": ["koa", "sax"]}]
+```
+
+This option lets you specify a list of packages that disable the rule when imported. By default, `koa` and `sax` are listed.
+
+With `koa`, this would still pass:
+
+```js
+const Koa = require('koa');
+const app = new Koa();
+
+app.onerror = () => {};
+```
diff --git a/rules/prefer-add-event-listener.js b/rules/prefer-add-event-listener.js
index fadf6d6cfb..f0ea39d102 100644
--- a/rules/prefer-add-event-listener.js
+++ b/rules/prefer-add-event-listener.js
@@ -55,6 +55,10 @@ const isClearing = node => {
 };
 
 const create = context => {
+	const options = context.options[0] || {};
+	const excludedPackages = new Set(options.excludedPackages || ['koa', 'sax']);
+	let isDisabled;
+
 	const nodeReturnsSomething = new WeakMap();
 	let codePathInfo = null;
 
@@ -72,11 +76,27 @@ const create = context => {
 			codePathInfo = codePathInfo.upper;
 		},
 
+		'CallExpression[callee.name="require"] > Literal'(node) {
+			if (!isDisabled && excludedPackages.has(node.value)) {
+				isDisabled = true;
+			}
+		},
+
+		'ImportDeclaration > Literal'(node) {
+			if (!isDisabled && excludedPackages.has(node.value)) {
+				isDisabled = true;
+			}
+		},
+
 		ReturnStatement(node) {
 			codePathInfo.returnsSomething = codePathInfo.returnsSomething || Boolean(node.argument);
 		},
 
 		'AssignmentExpression:exit'(node) {
+			if (isDisabled) {
+				return;
+			}
+
 			const {left: memberExpression, right: assignedExpression} = node;
 
 			if (memberExpression.type !== 'MemberExpression') {
@@ -118,12 +138,29 @@ const create = context => {
 	};
 };
 
+const schema = [
+	{
+		type: 'object',
+		properties: {
+			excludedPackages: {
+				type: 'array',
+				items: {
+					type: 'string'
+				},
+				uniqueItems: true
+			}
+		},
+		additionalProperties: false
+	}
+];
+
 module.exports = {
 	create,
 	meta: {
 		docs: {
 			url: getDocsUrl(__filename)
 		},
-		fixable: 'code'
+		fixable: 'code',
+		schema
 	}
 };


Test patch:
diff --git a/test/prefer-add-event-listener.js b/test/prefer-add-event-listener.js
index c6951a8c0f..56686ccbd0 100644
--- a/test/prefer-add-event-listener.js
+++ b/test/prefer-add-event-listener.js
@@ -5,9 +5,19 @@ import rule from '../rules/prefer-add-event-listener';
 const ruleTester = avaRuleTester(test, {
 	env: {
 		es6: true
+	},
+	parserOptions: {
+		sourceType: 'module'
 	}
 });
 
+const testCaseWithOptions = (code, options) => {
+	return {
+		code,
+		options
+	};
+};
+
 const invalidTestCase = (code, correctCode, eventType, message) => {
 	return {
 		code,
@@ -18,6 +28,15 @@ const invalidTestCase = (code, correctCode, eventType, message) => {
 	};
 };
 
+const invalidTestCaseWithOptions = (code, correctCode, eventType, options) => {
+	return {
+		code,
+		output: correctCode || code,
+		errors: [{message: `Prefer \`addEventListener\` over \`${eventType}\`.`}],
+		options
+	};
+};
+
 const expectedBeforeUnloadWithReturnMessage = [
 	'Prefer `addEventListener` over `onbeforeunload`.',
 	'Use `event.preventDefault(); event.returnValue = \'foo\'` to trigger the prompt.'
@@ -31,7 +50,39 @@ ruleTester.run('prefer-add-event-listener', rule, {
 		'foo.setCallBack = () => {console.log(\'foo\')}',
 		'setCallBack = () => {console.log(\'foo\')}',
 		'foo.onclick.bar = () => {}',
-		'foo[\'x\'] = true;'
+		'foo[\'x\'] = true;',
+		`const Koa = require('koa');
+		const app = new Koa();
+		
+		app.onerror = () => {};`,
+		`const sax = require('sax');
+		const parser = sax.parser();
+	  
+		parser.onerror = () => {};`,
+		`import Koa from 'koa';
+		const app = new Koa();
+		
+		app.onerror = () => {};`,
+		`import sax from 'sax';
+		const parser = sax.parser();
+	  
+		parser.onerror = () => {};`,
+		`import {sax as foo} from 'sax';
+		const parser = foo.parser();
+	  
+		parser.onerror = () => {};`,
+		testCaseWithOptions(
+			`const foo = require('foo');
+			
+			foo.onerror = () => {};`,
+			[{excludedPackages: ['foo']}]
+		),
+		testCaseWithOptions(
+			`import foo from 'foo';
+			
+			foo.onclick = () => {};`,
+			[{excludedPackages: ['foo']}]
+		)
 	],
 
 	invalid: [
@@ -156,6 +207,81 @@ ruleTester.run('prefer-add-event-listener', rule, {
 				console.log(e);
 			})`,
 			'onbeforeunload'
+		),
+		invalidTestCase(
+			`const foo = require('foo');
+
+			foo.onerror = () => {};
+			`,
+			`const foo = require('foo');
+
+			foo.addEventListener('error', () => {});
+			`,
+			'onerror'
+		),
+		invalidTestCase(
+			`import foo from 'foo';
+
+			foo.onerror = () => {};
+			`,
+			`import foo from 'foo';
+
+			foo.addEventListener('error', () => {});
+			`,
+			'onerror'
+		),
+		invalidTestCase(
+			`foo.onerror = () => {};
+
+			function bar() {
+				const koa = require('koa');
+
+				koa.onerror = () => {};
+			}`,
+			`foo.addEventListener('error', () => {});
+
+			function bar() {
+				const koa = require('koa');
+
+				koa.onerror = () => {};
+			}`,
+			'onerror'
+		),
+		invalidTestCaseWithOptions(
+			`const Koa = require('koa');
+			const app = new Koa();
+
+			app.onerror = () => {};`,
+			`const Koa = require('koa');
+			const app = new Koa();
+
+			app.addEventListener('error', () => {});`,
+			'onerror',
+			[{excludedPackages: ['foo']}]
+		),
+		invalidTestCaseWithOptions(
+			`import {Koa as Foo} from 'koa';
+			const app = new Foo();
+
+			app.onerror = () => {};`,
+			`import {Koa as Foo} from 'koa';
+			const app = new Foo();
+
+			app.addEventListener('error', () => {});`,
+			'onerror',
+			[{excludedPackages: ['foo']}]
+		),
+		invalidTestCaseWithOptions(
+			`const sax = require('sax');
+			const parser = sax.parser();
+
+			parser.onerror = () => {};`,
+			`const sax = require('sax');
+			const parser = sax.parser();
+
+			parser.addEventListener('error', () => {});`,
+			'onerror',
+			[{excludedPackages: []}]
 		)
 	]
 });