Skip to content

Commit ef94e49

Browse files
feat: add support for ignoring sync methods from certain locations (#424)
1 parent a500a48 commit ef94e49

File tree

18 files changed

+558
-10
lines changed

18 files changed

+558
-10
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: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@
44
*/
55
"use strict"
66

7+
let typeMatchesSpecifier =
8+
/** @type {import('ts-declaration-location').default | undefined} */
9+
(undefined)
10+
11+
try {
12+
typeMatchesSpecifier =
13+
/** @type {import('ts-declaration-location').default} */ (
14+
/** @type {unknown} */ (require("ts-declaration-location"))
15+
)
16+
17+
// eslint-disable-next-line no-empty -- Deliberately left empty.
18+
} catch {}
19+
const getTypeOfNode = require("../util/get-type-of-node")
20+
const getParserServices = require("../util/get-parser-services")
21+
const getFullTypeName = require("../util/get-full-type-name")
22+
723
const selectors = [
824
// fs.readFileSync()
925
// readFileSync.call(null, 'path')
@@ -16,7 +32,7 @@ const selectors = [
1632
* @typedef {[
1733
* {
1834
* allowAtRootLevel?: boolean
19-
* ignores?: string[]
35+
* ignores?: (string | { from: "file"; path?: string; name?: string[]; } | { from: "package"; package?: string; name?: string[]; } | { from: "lib"; name?: string[]; })[]
2036
* }?
2137
* ]} RuleOptions
2238
*/
@@ -40,7 +56,56 @@ module.exports = {
4056
},
4157
ignores: {
4258
type: "array",
43-
items: { type: "string" },
59+
items: {
60+
oneOf: [
61+
{ type: "string" },
62+
{
63+
type: "object",
64+
properties: {
65+
from: { const: "file" },
66+
path: {
67+
type: "string",
68+
},
69+
name: {
70+
type: "array",
71+
items: {
72+
type: "string",
73+
},
74+
},
75+
},
76+
additionalProperties: false,
77+
},
78+
{
79+
type: "object",
80+
properties: {
81+
from: { const: "lib" },
82+
name: {
83+
type: "array",
84+
items: {
85+
type: "string",
86+
},
87+
},
88+
},
89+
additionalProperties: false,
90+
},
91+
{
92+
type: "object",
93+
properties: {
94+
from: { const: "package" },
95+
package: {
96+
type: "string",
97+
},
98+
name: {
99+
type: "array",
100+
items: {
101+
type: "string",
102+
},
103+
},
104+
},
105+
additionalProperties: false,
106+
},
107+
],
108+
},
44109
default: [],
45110
},
46111
},
@@ -65,15 +130,70 @@ module.exports = {
65130
* @returns {void}
66131
*/
67132
[selector.join(",")](node) {
68-
if (ignores.includes(node.name)) {
69-
return
133+
const parserServices = getParserServices(context)
134+
135+
/**
136+
* @type {import('typescript').Type | undefined | null}
137+
*/
138+
let type = undefined
139+
140+
/**
141+
* @type {string | undefined | null}
142+
*/
143+
let fullName = undefined
144+
145+
for (const ignore of ignores) {
146+
if (typeof ignore === "string") {
147+
if (ignore === node.name) {
148+
return
149+
}
150+
151+
continue
152+
}
153+
154+
if (
155+
parserServices === null ||
156+
parserServices.program === null
157+
) {
158+
throw new Error(
159+
'TypeScript parser services not available. Rule "n/no-sync" is configured to use "ignores" option with a non-string value. This requires TypeScript parser services to be available.'
160+
)
161+
}
162+
163+
if (typeMatchesSpecifier === undefined) {
164+
throw new Error(
165+
'ts-declaration-location not available. Rule "n/no-sync" is configured to use "ignores" option with a non-string value. This requires ts-declaration-location to be available.'
166+
)
167+
}
168+
169+
type =
170+
type === undefined
171+
? getTypeOfNode(node, parserServices)
172+
: type
173+
174+
fullName =
175+
fullName === undefined
176+
? getFullTypeName(type)
177+
: fullName
178+
179+
if (
180+
typeMatchesSpecifier(
181+
parserServices.program,
182+
ignore,
183+
type
184+
) &&
185+
(ignore.name === undefined ||
186+
ignore.name.includes(fullName ?? node.name))
187+
) {
188+
return
189+
}
70190
}
71191

72192
context.report({
73193
node: node.parent,
74194
messageId: "noSync",
75195
data: {
76-
propertyName: node.name,
196+
propertyName: fullName ?? node.name,
77197
},
78198
})
79199
},

lib/util/get-full-type-name.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"use strict"
2+
3+
const ts = (() => {
4+
try {
5+
// eslint-disable-next-line n/no-unpublished-require
6+
return require("typescript")
7+
} catch {
8+
return null
9+
}
10+
})()
11+
12+
/**
13+
* @param {import('typescript').Type | null} type
14+
* @returns {string | null}
15+
*/
16+
module.exports = function getFullTypeName(type) {
17+
if (ts === null || type === null) {
18+
return null
19+
}
20+
21+
/**
22+
* @type {string[]}
23+
*/
24+
let nameParts = []
25+
let currentSymbol = type.getSymbol()
26+
while (currentSymbol !== undefined) {
27+
if (
28+
currentSymbol.valueDeclaration?.kind === ts.SyntaxKind.SourceFile ||
29+
currentSymbol.valueDeclaration?.kind ===
30+
ts.SyntaxKind.ModuleDeclaration
31+
) {
32+
break
33+
}
34+
35+
nameParts.unshift(currentSymbol.getName())
36+
currentSymbol =
37+
/** @type {import('typescript').Symbol & {parent: import('typescript').Symbol | undefined}} */ (
38+
currentSymbol
39+
).parent
40+
}
41+
42+
if (nameParts.length === 0) {
43+
return null
44+
}
45+
46+
return nameParts.join(".")
47+
}

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(/** @type {any} */ (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(/** @type {any} */ (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: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,23 @@
1818
},
1919
"dependencies": {
2020
"@eslint-community/eslint-utils": "^4.5.0",
21+
"@typescript-eslint/utils": "^8.26.1",
2122
"enhanced-resolve": "^5.17.1",
2223
"eslint-plugin-es-x": "^7.8.0",
2324
"get-tsconfig": "^4.8.1",
2425
"globals": "^15.11.0",
2526
"ignore": "^5.3.2",
2627
"minimatch": "^9.0.5",
27-
"semver": "^7.6.3"
28+
"semver": "^7.6.3",
29+
"ts-declaration-location": "^1.0.6"
2830
},
2931
"devDependencies": {
3032
"@eslint/js": "^9.14.0",
3133
"@types/eslint": "^9.6.1",
3234
"@types/estree": "^1.0.6",
3335
"@types/node": "^20.17.5",
34-
"@typescript-eslint/parser": "^8.12.2",
35-
"@typescript-eslint/typescript-estree": "^8.12.2",
36+
"@typescript-eslint/parser": "^8.26.1",
37+
"@typescript-eslint/typescript-estree": "^8.26.1",
3638
"eslint": "^9.14.0",
3739
"eslint-config-prettier": "^9.1.0",
3840
"eslint-doc-generator": "^1.7.1",
@@ -120,4 +122,4 @@
120122
"imports": {
121123
"#test-helpers": "./tests/test-helpers.js"
122124
}
123-
}
125+
}

tests/fixtures/no-sync/base/file.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// File needs to exists
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ESNext",
4+
"module": "ESNext",
5+
"moduleResolution": "Bundler",
6+
"strict": true,
7+
"skipLibCheck": true
8+
},
9+
"include": ["**/*"]
10+
}

tests/fixtures/no-sync/file.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// File needs to exists
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
// File needs to exists

tests/fixtures/no-sync/ignore-package/node_modules/aaa/index.d.ts

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/fixtures/no-sync/ignore-package/node_modules/aaa/index.js

Whitespace-only changes.

tests/fixtures/no-sync/ignore-package/node_modules/aaa/package.json

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"name": "test",
3+
"version": "0.0.0",
4+
"dependencies": {
5+
"aaa": "0.0.0"
6+
}
7+
}

0 commit comments

Comments
 (0)