Skip to content

Commit 1781930

Browse files
feat: add support for ignoring sync methods from certain locations
1 parent ccf5f9e commit 1781930

File tree

7 files changed

+299
-5
lines changed

7 files changed

+299
-5
lines changed

docs/rules/no-sync.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ fs.readFileSync(somePath).toString();
6161
#### ignores
6262

6363
You can `ignore` specific function names using this option.
64+
Additionally, if you are using TypeScript you can optionally specify where the function is declared.
6465

6566
Examples of **incorrect** code for this rule with the `{ ignores: ['readFileSync'] }` option:
6667

@@ -78,6 +79,62 @@ Examples of **correct** code for this rule with the `{ ignores: ['readFileSync']
7879
fs.readFileSync(somePath);
7980
```
8081

82+
##### Advanced (TypeScript only)
83+
84+
You can provide a list of specifiers to ignore. Specifiers are typed as follows:
85+
86+
```ts
87+
type Specifier =
88+
| string
89+
| {
90+
from: "file";
91+
path?: string;
92+
name?: string[];
93+
}
94+
| {
95+
from: "package";
96+
package?: string;
97+
name?: string[];
98+
}
99+
| {
100+
from: "lib";
101+
name?: string[];
102+
}
103+
```
104+
105+
###### From a file
106+
107+
Examples of **correct** code for this rule with the ignore file specifier:
108+
109+
```js
110+
/*eslint n/no-sync: ["error", { ignores: [{ from: 'file', path: './foo.ts' }]}] */
111+
112+
import { fooSync } from "./foo"
113+
fooSync()
114+
```
115+
116+
###### From a package
117+
118+
Examples of **correct** code for this rule with the ignore package specifier:
119+
120+
```js
121+
/*eslint n/no-sync: ["error", { ignores: [{ from: 'package', package: 'effect' }]}] */
122+
123+
import { Effect } from "effect"
124+
const value = Effect.runSync(Effect.succeed(42))
125+
```
126+
127+
###### From the TypeScript library
128+
129+
Examples of **correct** code for this rule with the ignore lib specifier:
130+
131+
```js
132+
/*eslint n/no-sync: ["error", { ignores: [{ from: 'lib' }]}] */
133+
134+
const stylesheet = new CSSStyleSheet()
135+
stylesheet.replaceSync("body { font-size: 1.4em; } p { color: red; }")
136+
```
137+
81138
## 🔎 Implementation
82139

83140
- [Rule source](../../lib/rules/no-sync.js)

lib/rules/no-sync.js

Lines changed: 88 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
*/
55
"use strict"
66

7+
const typeMatchesSpecifier = require("ts-declaration-location")
8+
const getTypeOfNode = require("../util/get-type-of-node")
9+
const getParserServices = require("../util/get-parser-services")
10+
711
const selectors = [
812
// fs.readFileSync()
913
// readFileSync.call(null, 'path')
@@ -32,7 +36,65 @@ module.exports = {
3236
},
3337
ignores: {
3438
type: "array",
35-
items: { type: "string" },
39+
items: {
40+
oneOf: [
41+
{ type: "string" },
42+
{
43+
type: "object",
44+
properties: {
45+
from: {
46+
type: "string",
47+
enum: ["file"],
48+
},
49+
path: {
50+
type: "string",
51+
},
52+
name: {
53+
type: "array",
54+
items: {
55+
type: "string",
56+
},
57+
},
58+
},
59+
additionalProperties: false,
60+
},
61+
{
62+
type: "object",
63+
properties: {
64+
from: {
65+
type: "string",
66+
enum: ["lib"],
67+
},
68+
name: {
69+
type: "array",
70+
items: {
71+
type: "string",
72+
},
73+
},
74+
},
75+
additionalProperties: false,
76+
},
77+
{
78+
type: "object",
79+
properties: {
80+
from: {
81+
type: "string",
82+
enum: ["package"],
83+
},
84+
package: {
85+
type: "string",
86+
},
87+
name: {
88+
type: "array",
89+
items: {
90+
type: "string",
91+
},
92+
},
93+
},
94+
additionalProperties: false,
95+
},
96+
],
97+
},
3698
default: [],
3799
},
38100
},
@@ -57,8 +119,31 @@ module.exports = {
57119
* @returns {void}
58120
*/
59121
[selector.join(",")](node) {
60-
if (ignores.includes(node.name)) {
61-
return
122+
const parserServices = getParserServices(context)
123+
124+
for (const ignore of ignores) {
125+
if (typeof ignore === "string") {
126+
if (ignore === node.name) {
127+
return
128+
}
129+
130+
continue
131+
}
132+
133+
if (parserServices !== null) {
134+
const type = getTypeOfNode(node, parserServices)
135+
if (
136+
typeMatchesSpecifier(
137+
parserServices.program,
138+
ignore,
139+
type
140+
) &&
141+
(ignore.name === undefined ||
142+
ignore.name.includes(node.name))
143+
) {
144+
return
145+
}
146+
}
62147
}
63148

64149
context.report({

lib/util/get-parser-services.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"use strict"
2+
3+
const {
4+
getParserServices: getParserServicesFromTsEslint,
5+
} = require("@typescript-eslint/utils/eslint-utils")
6+
7+
/**
8+
* Get the TypeScript parser services.
9+
* If TypeScript isn't present, returns `null`.
10+
*
11+
* @param {import('eslint').Rule.RuleContext} context - rule context
12+
* @returns {import('@typescript-eslint/parser').ParserServices | null}
13+
*/
14+
module.exports = function getParserServices(context) {
15+
// Not using tseslint parser?
16+
if (
17+
context.sourceCode.parserServices?.esTreeNodeToTSNodeMap == null ||
18+
context.sourceCode.parserServices.tsNodeToESTreeNodeMap == null
19+
) {
20+
return null
21+
}
22+
23+
return getParserServicesFromTsEslint(context, true)
24+
}

lib/util/get-type-of-node.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"use strict"
2+
3+
/**
4+
* Get the type of a node.
5+
* If TypeScript isn't present, returns `null`.
6+
*
7+
* @param {import('estree').Node} node - A node
8+
* @param {import('@typescript-eslint/parser').ParserServices} parserServices - A parserServices
9+
* @returns {import('typescript').Type | null}
10+
*/
11+
module.exports = function getTypeOfNode(node, parserServices) {
12+
const { esTreeNodeToTSNodeMap, program } = parserServices
13+
if (program === null) {
14+
return null
15+
}
16+
const tsNode = esTreeNodeToTSNodeMap.get(node)
17+
const checker = program.getTypeChecker()
18+
const nodeType = checker.getTypeAtLocation(tsNode)
19+
const constrained = checker.getBaseConstraintOfType(nodeType)
20+
return constrained ?? nodeType
21+
}

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@
1717
},
1818
"dependencies": {
1919
"@eslint-community/eslint-utils": "^4.4.1",
20+
"@typescript-eslint/utils": "^8.16.0",
2021
"enhanced-resolve": "^5.17.1",
2122
"eslint-plugin-es-x": "^7.8.0",
2223
"get-tsconfig": "^4.8.1",
2324
"globals": "^15.11.0",
2425
"ignore": "^5.3.2",
2526
"minimatch": "^9.0.5",
26-
"semver": "^7.6.3"
27+
"semver": "^7.6.3",
28+
"ts-declaration-location": "^1.0.5"
2729
},
2830
"devDependencies": {
2931
"@eslint/js": "^9.14.0",

tests/lib/rules/no-sync.js

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55
"use strict"
66

7-
const RuleTester = require("#test-helpers").RuleTester
7+
const { RuleTester, TsRuleTester } = require("#test-helpers")
88
const rule = require("../../../lib/rules/no-sync")
99

1010
new RuleTester().run("no-sync", rule, {
@@ -149,3 +149,88 @@ new RuleTester().run("no-sync", rule, {
149149
},
150150
],
151151
})
152+
153+
new TsRuleTester().run("no-sync", rule, {
154+
valid: [
155+
{
156+
code: `
157+
declare function fooSync(): void;
158+
fooSync();
159+
`,
160+
options: [
161+
{
162+
ignores: [
163+
{
164+
from: "file",
165+
},
166+
],
167+
},
168+
],
169+
},
170+
{
171+
code: `
172+
declare function fooSync(): void;
173+
fooSync();
174+
`,
175+
options: [
176+
{
177+
ignores: [
178+
{
179+
from: "file",
180+
name: ["fooSync"],
181+
},
182+
],
183+
},
184+
],
185+
},
186+
],
187+
invalid: [
188+
{
189+
code: `
190+
declare function fooSync(): void;
191+
fooSync();
192+
`,
193+
filename: "foo.ts",
194+
options: [
195+
{
196+
ignores: [
197+
{
198+
from: "file",
199+
path: "**/bar.ts",
200+
},
201+
],
202+
},
203+
],
204+
errors: [
205+
{
206+
messageId: "noSync",
207+
data: { propertyName: "fooSync" },
208+
type: "CallExpression",
209+
},
210+
],
211+
},
212+
{
213+
code: `
214+
declare function fooSync(): void;
215+
fooSync();
216+
`,
217+
options: [
218+
{
219+
ignores: [
220+
{
221+
from: "file",
222+
name: ["barSync"],
223+
},
224+
],
225+
},
226+
],
227+
errors: [
228+
{
229+
messageId: "noSync",
230+
data: { propertyName: "fooSync" },
231+
type: "CallExpression",
232+
},
233+
],
234+
},
235+
],
236+
})

tests/test-helpers.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const { FlatRuleTester } = require("eslint/use-at-your-own-risk")
99
const globals = require("globals")
1010
const semverSatisfies = require("semver/functions/satisfies")
1111
const os = require("os")
12+
const typescriptParser = require("@typescript-eslint/parser")
1213

1314
// greater than or equal to ESLint v9
1415
exports.gteEslintV9 = semverSatisfies(eslintVersion, ">=9", {
@@ -33,6 +34,18 @@ const defaultConfig = {
3334
globals: { ...globals.es2015, ...globals.node },
3435
},
3536
}
37+
const tsConfig = {
38+
languageOptions: {
39+
ecmaVersion: "latest",
40+
sourceType: "module",
41+
parser: typescriptParser,
42+
parserOptions: {
43+
projectService: {
44+
allowDefaultProject: ["*.*", "src/*"], // TODO: Don't use default project.
45+
},
46+
},
47+
},
48+
}
3649
exports.RuleTester = function (config = defaultConfig) {
3750
if (config.languageOptions.env?.node === false)
3851
config.languageOptions.globals = config.languageOptions.globals || {}
@@ -54,6 +67,13 @@ exports.RuleTester = function (config = defaultConfig) {
5467
}
5568
return ruleTester
5669
}
70+
exports.TsRuleTester = function (config = tsConfig) {
71+
return exports.RuleTester.call(this, config)
72+
}
73+
Object.setPrototypeOf(
74+
exports.TsRuleTester.prototype,
75+
exports.RuleTester.prototype
76+
)
5777

5878
// support skip in tests
5979
function shouldRun(item) {

0 commit comments

Comments
 (0)