Skip to content

Commit 8bff37e

Browse files
Add no-error-cause rule (#39)
* Add `no-error-cause` rule * Add tests for `no-error-cause` * Add docs for `no-error-cause` * Add `no-error-cause` to `no-new-in-es2022` * Export `no-error-cause` * Update docs by script * Track reference and AggregateError * Support extended Error * Update ruledoc by script * Support more error constructors * Support AggregateError constructor * Check `SpreadElement` * fix `no-error-cause` * format * update config Co-authored-by: Sosuke Suzuki <[email protected]>
1 parent ef4d4aa commit 8bff37e

File tree

7 files changed

+305
-3
lines changed

7 files changed

+305
-3
lines changed

docs/rules/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ There are multiple configs that enable all rules in this category: `plugin:es-x/
2323
| [es-x/no-array-string-prototype-at](./no-array-string-prototype-at.md) | disallow the `{Array,String}.prototype.at()` methods. | |
2424
| [es-x/no-class-fields](./no-class-fields.md) | disallow class fields. | |
2525
| [es-x/no-class-static-block](./no-class-static-block.md) | disallow class static block. | |
26+
| [es-x/no-error-cause](./no-error-cause.md) | disallow Error Cause. | |
2627
| [es-x/no-object-hasown](./no-object-hasown.md) | disallow the `Object.hasOwn` method. | |
2728
| [es-x/no-private-in](./no-private-in.md) | disallow `#x in obj`. | |
2829
| [es-x/no-regexp-d-flag](./no-regexp-d-flag.md) | disallow RegExp `d` flag. | |

docs/rules/no-error-cause.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
title: "es-x/no-error-cause"
3+
description: "disallow Error Cause"
4+
---
5+
6+
# es-x/no-error-cause
7+
> disallow Error Cause
8+
9+
- ❗ <badge text="This rule has not been released yet." vertical="middle" type="error"> ***This rule has not been released yet.*** </badge>
10+
- ✅ The following configurations enable this rule: `plugin:es-x/no-new-in-es2022`, `plugin:es-x/restrict-to-es3`, `plugin:es-x/restrict-to-es5`, `plugin:es-x/restrict-to-es2015`, `plugin:es-x/restrict-to-es2016`, `plugin:es-x/restrict-to-es2017`, `plugin:es-x/restrict-to-es2018`, `plugin:es-x/restrict-to-es2019`, `plugin:es-x/restrict-to-es2020`, and `plugin:es-x/restrict-to-es2021`
11+
12+
This rule reports ES2022 [Error Cause](https://github.com/tc39/proposal-error-cause).
13+
14+
## 💡 Examples
15+
16+
⛔ Examples of **incorrect** code for this rule:
17+
18+
<eslint-playground type="bad">
19+
20+
```js
21+
/*eslint es-x/no-error-cause: error */
22+
throw new Error('failed', { cause: err });
23+
```
24+
25+
</eslint-playground>
26+
27+
## 📚 References
28+
29+
- [Rule source](https://github.com/eslint-community/eslint-plugin-es-x/blob/master/lib/rules/no-error-cause.js)
30+
- [Test source](https://github.com/eslint-community/eslint-plugin-es-x/blob/master/tests/lib/rules/no-error-cause.js)

lib/configs/no-new-in-es2022.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ module.exports = {
1111
"es-x/no-array-string-prototype-at": "error",
1212
"es-x/no-class-fields": "error",
1313
"es-x/no-class-static-block": "error",
14+
"es-x/no-error-cause": "error",
1415
"es-x/no-object-hasown": "error",
1516
"es-x/no-private-in": "error",
1617
"es-x/no-regexp-d-flag": "error",

lib/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ module.exports = {
9696
"no-default-parameters": require("./rules/no-default-parameters"),
9797
"no-destructuring": require("./rules/no-destructuring"),
9898
"no-dynamic-import": require("./rules/no-dynamic-import"),
99+
"no-error-cause": require("./rules/no-error-cause"),
99100
"no-escape-unescape": require("./rules/no-escape-unescape"),
100101
"no-exponential-operators": require("./rules/no-exponential-operators"),
101102
"no-export-ns-from": require("./rules/no-export-ns-from"),

lib/rules/no-error-cause.js

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/**
2+
* @author Sosuke Suzuki <https://github.com/sosukesuzuki>
3+
* See LICENSE file in root directory for full license.
4+
*/
5+
6+
"use strict"
7+
8+
const {
9+
CONSTRUCT,
10+
READ,
11+
ReferenceTracker,
12+
getPropertyName,
13+
} = require("eslint-utils")
14+
15+
/**
16+
* @typedef {import("estree").Node} Node
17+
* @typedef {import("estree").ClassExpression | import("estree").ClassDeclaration} ClassNode
18+
* @typedef {import("estree").CallExpression} CallExpression
19+
*/
20+
21+
const errorConstructorNames = [
22+
"Error",
23+
"EvalError",
24+
"RangeError",
25+
"ReferenceError",
26+
"SyntaxError",
27+
"TypeError",
28+
"URIError",
29+
"AggregateError",
30+
]
31+
const errorsTraceMap = {}
32+
for (const errorConstructorName of errorConstructorNames) {
33+
errorsTraceMap[errorConstructorName] = { [CONSTRUCT]: true, [READ]: true }
34+
}
35+
36+
/**
37+
* @param {Node} node
38+
* @returns {boolean}
39+
*/
40+
function isSuperCall(node) {
41+
return node.type === "CallExpression" && node.callee.type === "Super"
42+
}
43+
44+
/**
45+
* @param {Node|undefined} node
46+
* @returns {boolean}
47+
*/
48+
function isSpreadElement(node) {
49+
return node && node.type === "SpreadElement"
50+
}
51+
52+
/**
53+
* @param {Node} node
54+
* @returns {ClassNode | null}
55+
*/
56+
function findClassFromAncestors(node) {
57+
if (node.type !== "ClassExpression" && node.type !== "ClassDeclaration") {
58+
return findClassFromAncestors(node.parent)
59+
}
60+
if (!node) {
61+
return null
62+
}
63+
return node
64+
}
65+
66+
module.exports = {
67+
meta: {
68+
docs: {
69+
description: "disallow Error Cause.",
70+
category: "ES2022",
71+
recommended: false,
72+
url: "http://eslint-community.github.io/eslint-plugin-es-x/rules/no-error-cause.html",
73+
},
74+
fixable: null,
75+
messages: {
76+
forbidden: "ES2022 Error Cause is forbidden.",
77+
},
78+
schema: [],
79+
type: "problem",
80+
},
81+
create(context) {
82+
/** @type {Array<{ classNode: ClassNode, superCallNode: CallExpression }>} */
83+
const maybeErrorSubclasses = []
84+
85+
/** @type {Array<{ classNode: ClassNode, superCallNode: CallExpression }>} */
86+
const maybeAggregateErrorSubclasses = []
87+
88+
/**
89+
* Checks if the received node is a constructor call with cause option.
90+
* e.g. `new Error("message", { cause: foo })`, `super("message", { cause: foo })`
91+
*
92+
* @param {Node} node
93+
* @param {boolean} isAggregateError
94+
* @returns {boolean}
95+
*/
96+
function isConstructCallWithCauseOption(node, isAggregateError) {
97+
if (node.type !== "NewExpression" && !isSuperCall(node)) {
98+
return false
99+
}
100+
const optionsArgIndex = isAggregateError ? 2 : 1
101+
for (let index = 0; index < optionsArgIndex; index++) {
102+
if (isSpreadElement(node.arguments[index])) {
103+
return false
104+
}
105+
}
106+
const optionsArg = node.arguments[optionsArgIndex]
107+
if (!optionsArg || optionsArg.type !== "ObjectExpression") {
108+
return false
109+
}
110+
return optionsArg.properties.some((property) => {
111+
if (property.type !== "Property") {
112+
return false
113+
}
114+
// new Error("msg", { cause: foo })
115+
return getPropertyName(property, context.getScope()) === "cause"
116+
})
117+
}
118+
119+
/**
120+
* @param {Node} node
121+
* @param {isAggregateError} boolean
122+
* @return {Node | null}
123+
*/
124+
function getReportedNode(node, isAggregateError) {
125+
const errorSubclasses = isAggregateError
126+
? maybeAggregateErrorSubclasses
127+
: maybeErrorSubclasses
128+
129+
if (errorSubclasses.length > 0) {
130+
for (const { classNode, superCallNode } of errorSubclasses) {
131+
if (classNode.superClass === node) {
132+
return superCallNode
133+
}
134+
}
135+
}
136+
if (isConstructCallWithCauseOption(node, isAggregateError)) {
137+
return node
138+
}
139+
return null
140+
}
141+
142+
return {
143+
Super(node) {
144+
const superCallNode = node.parent
145+
146+
function findErrorSubclasses(isAggregateError) {
147+
const errorSubclasses = isAggregateError
148+
? maybeAggregateErrorSubclasses
149+
: maybeErrorSubclasses
150+
151+
if (
152+
isConstructCallWithCauseOption(
153+
superCallNode,
154+
isAggregateError,
155+
)
156+
) {
157+
const classNode = findClassFromAncestors(superCallNode)
158+
if (classNode && classNode.superClass) {
159+
errorSubclasses.push({ classNode, superCallNode })
160+
}
161+
}
162+
}
163+
164+
findErrorSubclasses(/* isAggregateError */ false)
165+
findErrorSubclasses(/* isAggregateError */ true)
166+
},
167+
"Program:exit"() {
168+
const tracker = new ReferenceTracker(context.getScope())
169+
for (const { node, path } of tracker.iterateGlobalReferences(
170+
errorsTraceMap,
171+
)) {
172+
const reportedNode = getReportedNode(
173+
node,
174+
path.join(",") === "AggregateError",
175+
)
176+
if (reportedNode) {
177+
context.report({
178+
node: reportedNode,
179+
messageId: "forbidden",
180+
})
181+
}
182+
}
183+
},
184+
}
185+
},
186+
}

scripts/rules.js

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,17 @@ const rules = []
7474
.replace(/\.js$/u, "")
7575
.replace(/\\/gu, "/")
7676
const content = fs.readFileSync(filePath, "utf8")
77+
const contentWithoutComments = content.replace(
78+
/("(?:\\"|[^"])*?"|'(?:\\'|[^'])*?')|\/\/[^\n]*|\/\*[\s\S]*?\*\//gu,
79+
"$1",
80+
)
7781
const category = /category:[\s\n]+(?:undefined|"(.+)")/u.exec(
78-
content,
82+
contentWithoutComments,
7983
)[1]
80-
const description = /description:[\s\n]+"(.+?)\.?"/u.exec(content)[1]
81-
const fixable = /fixable:[\s\n]+"(.+)"/u.test(content)
84+
const description = /description:[\s\n]+"(.+?)\.?"/u.exec(
85+
contentWithoutComments,
86+
)[1]
87+
const fixable = /fixable:[\s\n]+"(.+)"/u.test(contentWithoutComments)
8288
const rule = {
8389
ruleId,
8490
description: JSON.parse(`"${description}"`),

tests/lib/rules/no-error-cause.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* @author Sosuke Suzuki <https://github.com/sosukesuzuki>
3+
* See LICENSE file in root directory for full license.
4+
*/
5+
6+
"use strict"
7+
8+
const RuleTester = require("../../tester")
9+
const rule = require("../../../lib/rules/no-error-cause.js")
10+
11+
if (!RuleTester.isSupported(2022)) {
12+
//eslint-disable-next-line no-console
13+
console.log("Skip the tests of no-error-cause.")
14+
return
15+
}
16+
17+
const errorConstructorNames = [
18+
"Error",
19+
"AggregateError",
20+
"EvalError",
21+
"RangeError",
22+
"ReferenceError",
23+
"SyntaxError",
24+
"TypeError",
25+
"URIError",
26+
]
27+
28+
function getErrors(errorConstructorName) {
29+
return errorConstructorName === "AggregateError"
30+
? '[new Error("message")], '
31+
: ""
32+
}
33+
34+
const valid = errorConstructorNames
35+
.map((errorConstructorName) => {
36+
const errors = getErrors(errorConstructorName)
37+
return [
38+
`new ${errorConstructorName}(${errors}"message")`,
39+
`new ${errorConstructorName}(${errors}"message", notObjectExpression)`,
40+
`new ${errorConstructorName}(${errors}"message", { notCause: foo })`,
41+
`new ${errorConstructorName}(${errors}...foo, { cause: foo });`,
42+
`class MyError extends ${errorConstructorName} { constructor() { super(${errors}"message") } }`,
43+
`class MyError extends ${errorConstructorName} { constructor() { super(${errors}"message", notObjectExpression) } }`,
44+
`class MyError extends ${errorConstructorName} { constructor() { super(${errors}"message", { notCause: foo }) } }`,
45+
]
46+
})
47+
// alternative of `Array.prototype.flat`
48+
.reduce((acc, val) => acc.concat(val), [])
49+
50+
const invalid = errorConstructorNames
51+
.map((errorConstructorName) => {
52+
const errors = getErrors(errorConstructorName)
53+
return [
54+
{
55+
code: `new ${errorConstructorName}(${errors}"message", { cause: foo });`,
56+
errors: ["ES2022 Error Cause is forbidden."],
57+
},
58+
{
59+
code: `new ${errorConstructorName}(${errors}"message", { ["cause"]: foo });`,
60+
errors: ["ES2022 Error Cause is forbidden."],
61+
},
62+
{
63+
code: `const MyError = ${errorConstructorName}; new MyError(${errors}"message", { ["cause"]: foo });`,
64+
errors: ["ES2022 Error Cause is forbidden."],
65+
},
66+
{
67+
code: `class MyError extends ${errorConstructorName} { constructor() { super(${errors}"message", { cause: foo }); } }`,
68+
errors: ["ES2022 Error Cause is forbidden."],
69+
},
70+
]
71+
})
72+
// alternative of `Array.prototype.flat`
73+
.reduce((acc, val) => acc.concat(val), [])
74+
75+
new RuleTester({
76+
parserOptions: { sourceType: "module" },
77+
}).run("no-error-cause", rule, { valid, invalid })

0 commit comments

Comments
 (0)