eslint-plugin-ocavue 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,194 @@
1
+ import { RuleTester } from 'eslint'
2
+ import tseslint from 'typescript-eslint'
3
+ import { test } from 'vitest'
4
+
5
+ import { useUtilsProjects } from '../utils/test-projects'
6
+
7
+ import rule from './prefer-sleep'
8
+
9
+ const js = useUtilsProjects('js')
10
+ const ts = useUtilsProjects('ts')
11
+
12
+ const IMPORT = "import { sleep } from '@ocavue/utils'\n"
13
+
14
+ test('prefer-sleep', () => {
15
+ const tester = new RuleTester({
16
+ languageOptions: { ecmaVersion: 'latest', sourceType: 'module' },
17
+ })
18
+ const filename = js.withUtils
19
+
20
+ tester.run('prefer-sleep', rule, {
21
+ valid: [
22
+ // The project does not depend on `@ocavue/utils`.
23
+ { code: 'await new Promise((r) => setTimeout(r, 100))', filename: js.withoutUtils },
24
+ // `@ocavue/utils` itself.
25
+ { code: 'await new Promise((r) => setTimeout(r, 100))', filename: js.utilsRepo },
26
+
27
+ // Not a plain delay.
28
+ { code: 'new Promise((r) => setTimeout(r))', filename },
29
+ { code: 'new Promise((r) => setTimeout(r, 100, value))', filename },
30
+ { code: 'new Promise((r) => setTimeout(() => r(value), 100))', filename },
31
+ { code: 'new Promise((r) => setTimeout(() => { g(); r() }, 100))', filename },
32
+ { code: 'new Promise((r) => setTimeout(other, 100))', filename },
33
+ { code: 'new Promise((r) => { g(); setTimeout(r, 100) })', filename },
34
+ { code: 'new Promise((r, reject) => setTimeout(reject, 100))', filename },
35
+ { code: 'new Promise(async (r) => setTimeout(r, 100))', filename },
36
+ { code: 'new Promise(({ r }) => setTimeout(r, 100))', filename },
37
+ { code: 'new Promise(() => setTimeout(g, 100))', filename },
38
+ { code: 'new Promise((r) => setTimeout(...args))', filename },
39
+ { code: 'new Promise((r) => setInterval(r, 100))', filename },
40
+ { code: 'new Promise((r) => foo.setTimeout(r, 100))', filename },
41
+
42
+ // The delay uses the executor's parameters, `this` or `arguments`.
43
+ { code: 'new Promise((r) => setTimeout(r, r.length))', filename },
44
+ { code: 'new Promise((r, reject) => setTimeout(r, reject.length))', filename },
45
+ { code: 'new Promise(function (r) { setTimeout(r, this.ms) })', filename },
46
+ { code: 'new Promise(function (r) { setTimeout(r, arguments.length) })', filename },
47
+
48
+ // Local bindings that shadow globals.
49
+ { code: 'const Promise = P\nnew Promise((r) => setTimeout(r, 100))', filename },
50
+ { code: 'const setTimeout = t\nnew Promise((r) => setTimeout(r, 100))', filename },
51
+
52
+ // Already uses `sleep`.
53
+ { code: `${IMPORT}await sleep(100)`, filename },
54
+ ],
55
+ invalid: [
56
+ {
57
+ code: 'await new Promise((resolve) => setTimeout(resolve, 100))',
58
+ output: `${IMPORT}await sleep(100)`,
59
+ filename,
60
+ errors: [{ messageId: 'preferSleep' }],
61
+ },
62
+ {
63
+ code: 'await new Promise(r => setTimeout(r, ms))',
64
+ output: `${IMPORT}await sleep(ms)`,
65
+ filename,
66
+ errors: [{ messageId: 'preferSleep' }],
67
+ },
68
+ {
69
+ code: 'await new Promise((resolve) => { setTimeout(resolve, 100) })',
70
+ output: `${IMPORT}await sleep(100)`,
71
+ filename,
72
+ errors: [{ messageId: 'preferSleep' }],
73
+ },
74
+ {
75
+ code: 'await new Promise((resolve) => { return setTimeout(resolve, 100) })',
76
+ output: `${IMPORT}await sleep(100)`,
77
+ filename,
78
+ errors: [{ messageId: 'preferSleep' }],
79
+ },
80
+ {
81
+ code: 'await new Promise((resolve) => setTimeout(() => resolve(), 100))',
82
+ output: `${IMPORT}await sleep(100)`,
83
+ filename,
84
+ errors: [{ messageId: 'preferSleep' }],
85
+ },
86
+ {
87
+ code: 'await new Promise((resolve) => setTimeout(() => { resolve() }, 100))',
88
+ output: `${IMPORT}await sleep(100)`,
89
+ filename,
90
+ errors: [{ messageId: 'preferSleep' }],
91
+ },
92
+ {
93
+ code: 'await new Promise(function (resolve) { setTimeout(resolve, 100) })',
94
+ output: `${IMPORT}await sleep(100)`,
95
+ filename,
96
+ errors: [{ messageId: 'preferSleep' }],
97
+ },
98
+ {
99
+ code: 'await new Promise((resolve, _reject) => setTimeout(resolve, 100))',
100
+ output: `${IMPORT}await sleep(100)`,
101
+ filename,
102
+ errors: [{ messageId: 'preferSleep' }],
103
+ },
104
+ {
105
+ code: 'await new Promise((r) => globalThis.setTimeout(r, 100))',
106
+ output: `${IMPORT}await sleep(100)`,
107
+ filename,
108
+ errors: [{ messageId: 'preferSleep' }],
109
+ },
110
+ {
111
+ code: 'await new Promise((r) => window.setTimeout(r, 100))',
112
+ output: `${IMPORT}await sleep(100)`,
113
+ filename,
114
+ errors: [{ messageId: 'preferSleep' }],
115
+ },
116
+ {
117
+ code: 'await new Promise((r) => setTimeout(r, this.delay * 2))',
118
+ output: `${IMPORT}await sleep(this.delay * 2)`,
119
+ filename,
120
+ errors: [{ messageId: 'preferSleep' }],
121
+ },
122
+ {
123
+ code: 'await new Promise((r) => setTimeout(r, (a, b)))',
124
+ output: `${IMPORT}await sleep((a, b))`,
125
+ filename,
126
+ errors: [{ messageId: 'preferSleep' }],
127
+ },
128
+ {
129
+ code: 'const wait = () => new Promise((r) => setTimeout(r, 0))',
130
+ output: `${IMPORT}const wait = () => sleep(0)`,
131
+ filename,
132
+ errors: [{ messageId: 'preferSleep' }],
133
+ },
134
+ {
135
+ code: "import { noop } from '@ocavue/utils'\nawait new Promise((r) => setTimeout(r, 100))",
136
+ output: "import { noop, sleep } from '@ocavue/utils'\nawait sleep(100)",
137
+ filename,
138
+ errors: [{ messageId: 'preferSleep' }],
139
+ },
140
+ {
141
+ // Reported without a fix.
142
+ code: 'const sleep = 1\nawait new Promise((r) => setTimeout(r, 100))',
143
+ output: null,
144
+ filename,
145
+ errors: [{ messageId: 'preferSleep' }],
146
+ },
147
+ {
148
+ code: 'await new Promise((r) => setTimeout(r, 100 /* ms */))',
149
+ output: null,
150
+ filename,
151
+ errors: [{ messageId: 'preferSleep' }],
152
+ },
153
+ {
154
+ code: 'await new Promise((r) => setTimeout(r, 100))',
155
+ output: `${IMPORT}await sleep(100)`,
156
+ filename: js.withoutUtils,
157
+ options: [{ checkDependency: false }],
158
+ errors: [{ messageId: 'preferSleep' }],
159
+ },
160
+ ],
161
+ })
162
+ })
163
+
164
+ test('prefer-sleep with TypeScript', () => {
165
+ const tester = new RuleTester({
166
+ languageOptions: {
167
+ parser: tseslint.parser,
168
+ ecmaVersion: 'latest',
169
+ sourceType: 'module',
170
+ },
171
+ })
172
+ const filename = ts.withUtils
173
+
174
+ tester.run('prefer-sleep', rule, {
175
+ valid: [
176
+ // `sleep` returns `Promise<void>`.
177
+ { code: 'await new Promise<number>((r) => setTimeout(r, 100))', filename },
178
+ ],
179
+ invalid: [
180
+ {
181
+ code: 'await new Promise<void>((resolve) => setTimeout(resolve, 100))',
182
+ output: `${IMPORT}await sleep(100)`,
183
+ filename,
184
+ errors: [{ messageId: 'preferSleep' }],
185
+ },
186
+ {
187
+ code: 'await new Promise<undefined>((r) => setTimeout(r, ms as number))',
188
+ output: `${IMPORT}await sleep(ms as number)`,
189
+ filename,
190
+ errors: [{ messageId: 'preferSleep' }],
191
+ },
192
+ ],
193
+ })
194
+ })
@@ -0,0 +1,228 @@
1
+ import type { Rule, Scope } from 'eslint'
2
+ import type {
3
+ ArrowFunctionExpression,
4
+ CallExpression,
5
+ Expression,
6
+ FunctionExpression,
7
+ Node,
8
+ SpreadElement,
9
+ } from 'estree'
10
+
11
+ import {
12
+ createImportFix,
13
+ findVariable,
14
+ isPackageAvailable,
15
+ packageRuleSchema,
16
+ UTILS_PACKAGE,
17
+ } from '../utils/package-import'
18
+
19
+ type FunctionNode = ArrowFunctionExpression | FunctionExpression
20
+
21
+ function isFunction(node: Node): node is FunctionNode {
22
+ return node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression'
23
+ }
24
+
25
+ /**
26
+ * Returns true when `name` refers to a global variable, not a local binding.
27
+ */
28
+ function isGlobal(scope: Scope.Scope, name: string): boolean {
29
+ const variable = findVariable(scope, name)
30
+ return !variable || variable.defs.length === 0
31
+ }
32
+
33
+ /**
34
+ * Returns the only expression that a function body evaluates, for bodies
35
+ * such as `x`, `{ x }` and `{ return x }`.
36
+ */
37
+ function getOnlyExpression(node: FunctionNode): Expression | undefined {
38
+ const body = node.body
39
+ if (body.type !== 'BlockStatement') {
40
+ return body
41
+ }
42
+ if (body.body.length !== 1) {
43
+ return undefined
44
+ }
45
+ const statement = body.body[0]
46
+ if (statement.type === 'ExpressionStatement') {
47
+ return statement.expression
48
+ }
49
+ if (statement.type === 'ReturnStatement' && statement.argument) {
50
+ return statement.argument
51
+ }
52
+ return undefined
53
+ }
54
+
55
+ function isIdentifier(node: Node | SpreadElement, name: string): boolean {
56
+ return node.type === 'Identifier' && node.name === name
57
+ }
58
+
59
+ /**
60
+ * Returns true for `setTimeout`, `globalThis.setTimeout` and
61
+ * `window.setTimeout`.
62
+ */
63
+ function isSetTimeout(node: Expression, scope: Scope.Scope): boolean {
64
+ if (node.type === 'Identifier') {
65
+ return node.name === 'setTimeout' && isGlobal(scope, 'setTimeout')
66
+ }
67
+ return (
68
+ node.type === 'MemberExpression' &&
69
+ !node.computed &&
70
+ isIdentifier(node.property, 'setTimeout') &&
71
+ node.object.type === 'Identifier' &&
72
+ (node.object.name === 'globalThis' || node.object.name === 'window') &&
73
+ isGlobal(scope, node.object.name)
74
+ )
75
+ }
76
+
77
+ /**
78
+ * Returns true for `resolve` and `() => resolve()`.
79
+ */
80
+ function isResolveCallback(node: Expression | SpreadElement, resolve: string): boolean {
81
+ if (isIdentifier(node, resolve)) {
82
+ return true
83
+ }
84
+ if (!isFunction(node) || node.async || node.generator || node.params.length > 0) {
85
+ return false
86
+ }
87
+ const expression = getOnlyExpression(node)
88
+ return (
89
+ expression?.type === 'CallExpression' &&
90
+ isIdentifier(expression.callee, resolve) &&
91
+ expression.arguments.length === 0
92
+ )
93
+ }
94
+
95
+ /**
96
+ * Returns true for no type argument, `<void>` and `<undefined>`.
97
+ */
98
+ function hasVoidTypeArgument(node: Node): boolean {
99
+ const typeArguments = (node as { typeArguments?: { params: { type: string }[] } }).typeArguments
100
+ if (!typeArguments) {
101
+ return true
102
+ }
103
+ return (
104
+ typeArguments.params.length === 1 &&
105
+ (typeArguments.params[0].type === 'TSVoidKeyword' ||
106
+ typeArguments.params[0].type === 'TSUndefinedKeyword')
107
+ )
108
+ }
109
+
110
+ const rule: Rule.RuleModule = {
111
+ meta: {
112
+ type: 'suggestion',
113
+ fixable: 'code',
114
+ docs: {
115
+ description:
116
+ 'enforce `sleep` from `@ocavue/utils` instead of `new Promise` with `setTimeout`',
117
+ url: 'https://github.com/ocavue/eslint-plugin-ocavue#prefer-sleep',
118
+ },
119
+ messages: {
120
+ preferSleep: 'Use `sleep` from `@ocavue/utils` instead of `new Promise` with `setTimeout`.',
121
+ },
122
+ schema: packageRuleSchema,
123
+ },
124
+ create(context) {
125
+ if (!isPackageAvailable(context, UTILS_PACKAGE)) {
126
+ return {}
127
+ }
128
+
129
+ const sourceCode = context.sourceCode
130
+
131
+ /**
132
+ * Returns the delay expression of `new Promise((resolve) =>
133
+ * setTimeout(resolve, delay))`, or `undefined` when the node does not
134
+ * match.
135
+ */
136
+ function getDelay(node: Node): Expression | undefined {
137
+ if (
138
+ node.type !== 'NewExpression' ||
139
+ !isIdentifier(node.callee, 'Promise') ||
140
+ node.arguments.length !== 1 ||
141
+ !hasVoidTypeArgument(node)
142
+ ) {
143
+ return undefined
144
+ }
145
+ if (!isGlobal(sourceCode.getScope(node), 'Promise')) {
146
+ return undefined
147
+ }
148
+
149
+ const executor = node.arguments[0]
150
+ if (!isFunction(executor) || executor.async || executor.generator) {
151
+ return undefined
152
+ }
153
+ if (!executor.params.every((param) => param.type === 'Identifier')) {
154
+ return undefined
155
+ }
156
+ const resolveParam = executor.params[0]
157
+ if (resolveParam?.type !== 'Identifier') {
158
+ return undefined
159
+ }
160
+
161
+ const call = getOnlyExpression(executor)
162
+ if (call?.type !== 'CallExpression' || call.optional) {
163
+ return undefined
164
+ }
165
+ const executorScope = sourceCode.getScope(executor)
166
+ if (!isSetTimeout(call.callee as Expression, executorScope)) {
167
+ return undefined
168
+ }
169
+
170
+ const [callback, delay] = (call as CallExpression).arguments
171
+ if (call.arguments.length !== 2 || !delay || delay.type === 'SpreadElement') {
172
+ return undefined
173
+ }
174
+ if (!isResolveCallback(callback, resolveParam.name)) {
175
+ return undefined
176
+ }
177
+
178
+ // The delay moves out of the executor, so it must not use the
179
+ // executor's parameters, `this` or `arguments`.
180
+ const usesExecutorParams = executorScope.variables.some((variable) => {
181
+ return variable.references.some((reference) => {
182
+ const range = reference.identifier.range!
183
+ return range[0] >= delay.range![0] && range[1] <= delay.range![1]
184
+ })
185
+ })
186
+ if (usesExecutorParams) {
187
+ return undefined
188
+ }
189
+ if (
190
+ executor.type === 'FunctionExpression' &&
191
+ sourceCode.getTokens(delay).some((token) => {
192
+ return token.value === 'this' || token.value === 'arguments'
193
+ })
194
+ ) {
195
+ return undefined
196
+ }
197
+
198
+ return delay
199
+ }
200
+
201
+ return {
202
+ NewExpression(node) {
203
+ const delay = getDelay(node)
204
+ if (!delay) {
205
+ return
206
+ }
207
+ const delayText = sourceCode.getText(delay)
208
+ context.report({
209
+ node,
210
+ messageId: 'preferSleep',
211
+ fix: createImportFix({
212
+ context,
213
+ node,
214
+ name: 'sleep',
215
+ packageName: UTILS_PACKAGE,
216
+ replace: (local) => {
217
+ return delay.type === 'SequenceExpression'
218
+ ? `${local}((${delayText}))`
219
+ : `${local}(${delayText})`
220
+ },
221
+ }),
222
+ })
223
+ },
224
+ }
225
+ },
226
+ }
227
+
228
+ export default rule
@@ -0,0 +1,102 @@
1
+ import type { Rule } from 'eslint'
2
+ import type { ArrowFunctionExpression, Expression, FunctionExpression, Node } from 'estree'
3
+
4
+ export type FunctionNode = (ArrowFunctionExpression | FunctionExpression) & Rule.NodeParentExtension
5
+
6
+ /**
7
+ * The value that a constant function returns.
8
+ */
9
+ export type ConstantValue = 'undefined' | 'true' | 'false' | 'null'
10
+
11
+ function getConstantValue(node: Expression): ConstantValue | undefined {
12
+ if (node.type === 'Literal') {
13
+ switch (node.raw) {
14
+ case 'true':
15
+ case 'false':
16
+ case 'null':
17
+ return node.raw
18
+ }
19
+ return undefined
20
+ }
21
+ if (node.type === 'Identifier' && node.name === 'undefined') {
22
+ return 'undefined'
23
+ }
24
+ if (
25
+ node.type === 'UnaryExpression' &&
26
+ node.operator === 'void' &&
27
+ node.argument.type === 'Literal'
28
+ ) {
29
+ return 'undefined'
30
+ }
31
+ return undefined
32
+ }
33
+
34
+ function getReturnedValue(node: FunctionNode): ConstantValue | undefined {
35
+ const body = node.body
36
+ if (body.type !== 'BlockStatement') {
37
+ return getConstantValue(body)
38
+ }
39
+ if (body.body.length === 0) {
40
+ return 'undefined'
41
+ }
42
+ if (body.body.length === 1) {
43
+ const statement = body.body[0]
44
+ if (statement.type === 'ReturnStatement') {
45
+ return statement.argument ? getConstantValue(statement.argument) : 'undefined'
46
+ }
47
+ }
48
+ return undefined
49
+ }
50
+
51
+ /**
52
+ * Returns true when the function can be swapped with a function reference
53
+ * without changing runtime behavior.
54
+ */
55
+ function isReplaceable(node: FunctionNode): boolean {
56
+ if (node.async || node.generator) {
57
+ return false
58
+ }
59
+ if ((node as { typeParameters?: unknown }).typeParameters) {
60
+ return false
61
+ }
62
+
63
+ // Every parameter must be a plain identifier. Default values may have side
64
+ // effects, and destructuring may throw.
65
+ const hasPlainParams = node.params.every((param) => {
66
+ return (
67
+ param.type === 'Identifier' ||
68
+ (param.type === 'RestElement' && param.argument.type === 'Identifier')
69
+ )
70
+ })
71
+ if (!hasPlainParams) {
72
+ return false
73
+ }
74
+
75
+ if (node.type === 'FunctionExpression') {
76
+ if (node.id) {
77
+ return false
78
+ }
79
+ const parent: Node = node.parent
80
+ if (parent.type === 'Property' && (parent.method || parent.kind !== 'init')) {
81
+ return false
82
+ }
83
+ if (parent.type === 'MethodDefinition') {
84
+ return false
85
+ }
86
+ if (parent.type === 'NewExpression' && parent.callee === node) {
87
+ return false
88
+ }
89
+ }
90
+
91
+ return true
92
+ }
93
+
94
+ /**
95
+ * Returns the value that an inline function always returns, such as
96
+ * `'true'` for `() => true` or `'undefined'` for `() => {}`. Returns
97
+ * `undefined` when the function does more than that, or when replacing it
98
+ * could change runtime behavior.
99
+ */
100
+ export function getConstantFunctionValue(node: FunctionNode): ConstantValue | undefined {
101
+ return isReplaceable(node) ? getReturnedValue(node) : undefined
102
+ }
@@ -0,0 +1,81 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ interface PackageJson {
5
+ name?: string
6
+ dependencies?: Record<string, string>
7
+ devDependencies?: Record<string, string>
8
+ peerDependencies?: Record<string, string>
9
+ }
10
+
11
+ // Maps a directory to its nearest `package.json`, or `null` when there is none.
12
+ const cache = new Map<string, PackageJson | null>()
13
+
14
+ function readPackageJson(dir: string): PackageJson | undefined {
15
+ let text: string
16
+ try {
17
+ text = fs.readFileSync(path.join(dir, 'package.json'), 'utf8')
18
+ } catch {
19
+ return undefined
20
+ }
21
+ try {
22
+ const json: unknown = JSON.parse(text)
23
+ return json && typeof json === 'object' ? json : {}
24
+ } catch {
25
+ return {}
26
+ }
27
+ }
28
+
29
+ function findNearestPackageJson(startDir: string): PackageJson | null {
30
+ const visited: string[] = []
31
+ let dir = startDir
32
+ let result: PackageJson | null = null
33
+
34
+ while (true) {
35
+ const cached = cache.get(dir)
36
+ if (cached !== undefined) {
37
+ result = cached
38
+ break
39
+ }
40
+ visited.push(dir)
41
+
42
+ const pkg = readPackageJson(dir)
43
+ if (pkg) {
44
+ result = pkg
45
+ break
46
+ }
47
+
48
+ const parent = path.dirname(dir)
49
+ if (parent === dir) {
50
+ break
51
+ }
52
+ dir = parent
53
+ }
54
+
55
+ for (const visitedDir of visited) {
56
+ cache.set(visitedDir, result)
57
+ }
58
+ return result
59
+ }
60
+
61
+ /**
62
+ * Returns true when the nearest `package.json` of `filename` lists
63
+ * `packageName` in `dependencies`, `devDependencies` or `peerDependencies`.
64
+ *
65
+ * A package never depends on itself, so this returns false inside the
66
+ * package named `packageName`.
67
+ */
68
+ export function hasDependency(filename: string, packageName: string): boolean {
69
+ if (!path.isAbsolute(filename)) {
70
+ return false
71
+ }
72
+
73
+ const pkg = findNearestPackageJson(path.dirname(filename))
74
+ if (!pkg || pkg.name === packageName) {
75
+ return false
76
+ }
77
+
78
+ return [pkg.dependencies, pkg.devDependencies, pkg.peerDependencies].some(
79
+ (deps) => deps != null && typeof deps === 'object' && Object.hasOwn(deps, packageName),
80
+ )
81
+ }