@nitra/cursor 1.8.185 → 1.8.188
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.
- package/CHANGELOG.md +28 -0
- package/mdc/js-run.mdc +13 -1
- package/mdc/vue.mdc +41 -2
- package/package.json +1 -1
- package/scripts/check-js-run.mjs +37 -1
- package/scripts/check-vue.mjs +45 -0
- package/scripts/utils/promise-settimeout-scan.mjs +129 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,34 @@
|
|
|
4
4
|
|
|
5
5
|
Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
|
|
6
6
|
|
|
7
|
+
## [1.8.188] - 2026-05-07
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- `vue` (mdc v1.6 → v1.7): для Volar/асетів канонічно лише **`jsconfig.json`** у корені пакета — прибрано альтернативу з `tsconfig.json`. `check-vue.mjs`: перевіряється лише наявність `jsconfig.json`.
|
|
12
|
+
|
|
13
|
+
## [1.8.187] - 2026-05-07
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- `check-vue.mjs`: перевірка `src/vite-env.d.ts` з `/// <reference types="vite/client" />` та наявності `jsconfig.json` або `tsconfig.json` у корені кожного Vue-пакета (типи для імпортів асетів у `.vue`).
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- `vue` (mdc v1.5 → v1.6): секція **«Vite client types (Volar, імпорти асетів)»** — обов’язкові `vite-env.d.ts`, jsconfig/tsconfig; застереження щодо вузького `compilerOptions.types`. Оновлено блок **«Перевірка»**.
|
|
22
|
+
|
|
23
|
+
## [1.8.186] - 2026-05-07
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- `check-js-run.mjs` + `scripts/utils/promise-settimeout-scan.mjs`: програмна перевірка нової секції js-run «Паузи через setTimeout». AST-сканер на `oxc-parser` ловить `new Promise(resolve => setTimeout(resolve, ms))` (з `await` чи без, arrow та function expression, concise та block body, тривіально загорнутий callback `() => resolve()`). Паттерни з передачею значення (`r => setTimeout(() => r(value), ms)`), іншим callback-ом замість resolve, або з додатковими стейтментами в блоці — поза правилом (це не «чиста» пауза).
|
|
28
|
+
- `tests/promise-settimeout-scan.test.mjs`: 13 модульних тестів (await/без, block-body, function expression, обгорнутий callback, false-positive guards, multiline номер рядка, кілька входжень, фільтр розширень).
|
|
29
|
+
- `tests/check-js-run-fixture.test.mjs`: 2 інтеграційні кейси на `check()` — fail при `await new Promise(r => setTimeout(r, ms))` у workspace-пакеті, pass при `await setTimeout(ms)` з `node:timers/promises`.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- `js-run` (mdc v1.3 → v1.4): додано секцію **«Паузи через setTimeout»** — заборонено `await new Promise(resolve => setTimeout(resolve, ms))`, замість цього треба `await setTimeout(ms)` з `node:timers/promises`. Зауваження про затінення глобального `setTimeout` у тому ж файлі (за потреби callback-варіант імпортувати під іншим іменем, наприклад `setTimeoutCb` з `node:timers`).
|
|
34
|
+
|
|
7
35
|
## [1.8.185] - 2026-05-06
|
|
8
36
|
|
|
9
37
|
### Changed
|
package/mdc/js-run.mdc
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Це правила для backend проектів на JavaScript/Node.js, сюди входять і job і WEB сервери.
|
|
3
3
|
alwaysApply: true
|
|
4
|
-
version: '1.
|
|
4
|
+
version: '1.4'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
## Область застосування
|
|
@@ -136,6 +136,18 @@ console.log(env.OPTIONAL_ENV_VAR)
|
|
|
136
136
|
`// @nitra/cursor ignore-next-line checkEnv` безпосередньо перед використанням
|
|
137
137
|
(escape-hatch для legacy-коду, не для нових файлів).
|
|
138
138
|
|
|
139
|
+
## Паузи через setTimeout
|
|
140
|
+
|
|
141
|
+
Заборонено робити паузи через `await new Promise(resolve => setTimeout(resolve, ms))` — таку обгортку треба замінити на promise-варіант `setTimeout` з `node:timers/promises`:
|
|
142
|
+
|
|
143
|
+
```javascript title="Замість new Promise + setTimeout"
|
|
144
|
+
import { setTimeout } from 'node:timers/promises'
|
|
145
|
+
|
|
146
|
+
await setTimeout(500)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Імпорт `setTimeout` з `node:timers/promises` затіняє глобальний таймер у файлі — якщо в тому ж файлі потрібен callback-варіант, імпортуй його під іншим іменем (наприклад, `import { setTimeout as setTimeoutCb } from 'node:timers'`).
|
|
150
|
+
|
|
139
151
|
## depcheck у GitHub Actions з path-фільтром
|
|
140
152
|
|
|
141
153
|
Якщо в `.github/workflows/*.yml` є тригер з `paths:`, який обмежує запуск workflow змінами в каталозі конкретного backend-пакета, в job цього workflow має бути крок `npx depcheck` з `working-directory`, який вказує на той самий каталог пакета. Це гарантує, що декларація залежностей у `package.json` пакета відповідає реальним імпортам — інакше можна випадково зламати білд після видалення «зайвої» залежності, яка насправді використовується через побічний імпорт.
|
package/mdc/vue.mdc
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
description: Vue
|
|
3
3
|
alwaysApply: true
|
|
4
|
-
version: '1.
|
|
4
|
+
version: '1.7'
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
# Vue 3 Composition API — правила для .cursorrules
|
|
@@ -208,6 +208,45 @@ export default defineConfig({
|
|
|
208
208
|
})
|
|
209
209
|
```
|
|
210
210
|
|
|
211
|
+
## Vite client types (Volar, імпорти асетів)
|
|
212
|
+
|
|
213
|
+
Без типів **Vite** редактор (Volar / TypeScript) не знає, що імпорт статичного файлу (`import url from './hero.avif'`, `*.png`, `*.svg` тощо) відповідає модулю з `string` URL. Тоді у `.vue` з’являється помилка на кшталт **Cannot find module '…' or its corresponding type declarations**.
|
|
214
|
+
|
|
215
|
+
У **кожному** workspace-пакеті з **Vue + Vite** обов’язково:
|
|
216
|
+
|
|
217
|
+
1. **`src/vite-env.d.ts`** — рівно з посиланням на клієнтські типи Vite (одного рядка достатньо):
|
|
218
|
+
|
|
219
|
+
```ts title="src/vite-env.d.ts"
|
|
220
|
+
/// <reference types="vite/client" />
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Так підтягуються декларації з `vite/client.d.ts` (`declare module '*.avif'`, `*.png`, …).
|
|
224
|
+
|
|
225
|
+
2. **Корінь пакета:** **`jsconfig.json`** із **`include`**, що охоплює `src` (наприклад `"include": ["src/**/*"]`), щоб мова служби бачила `vite-env.d.ts` і SFC.
|
|
226
|
+
|
|
227
|
+
Мінімальний приклад для JS-пакета:
|
|
228
|
+
|
|
229
|
+
```json title="jsconfig.json"
|
|
230
|
+
{
|
|
231
|
+
"compilerOptions": {
|
|
232
|
+
"target": "ESNext",
|
|
233
|
+
"module": "ESNext",
|
|
234
|
+
"moduleResolution": "bundler",
|
|
235
|
+
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
|
236
|
+
"jsx": "preserve",
|
|
237
|
+
"strict": true,
|
|
238
|
+
"noEmit": true,
|
|
239
|
+
"skipLibCheck": true,
|
|
240
|
+
"resolveJsonModule": true,
|
|
241
|
+
"isolatedModules": true,
|
|
242
|
+
"allowJs": true
|
|
243
|
+
},
|
|
244
|
+
"include": ["src/**/*"]
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**Не** звужуй без потреби **`compilerOptions.types`** до `["vite/client"]`: це може відрізати інші пакети з `@types` і зламати інші підказки. Достатньо `/// <reference types="vite/client" />` у `vite-env.d.ts` і коректного `include`.
|
|
249
|
+
|
|
211
250
|
## Тести
|
|
212
251
|
|
|
213
252
|
Проекту повинен бути покритий тестами E2E за допомогою Playwright.
|
|
@@ -287,4 +326,4 @@ import path from 'node:path'
|
|
|
287
326
|
|
|
288
327
|
## Перевірка
|
|
289
328
|
|
|
290
|
-
`npx @nitra/cursor check vue` — перевіряє залежності, `vite.config`,
|
|
329
|
+
`npx @nitra/cursor check vue` — перевіряє залежності, `vite.config`, наявність **`src/vite-env.d.ts`** з `/// <reference types="vite/client" />` та **`jsconfig.json`** у корені Vue-пакета; обходить джерела Vue-пакета (`.vue`, `.ts`, `.js` тощо) на заборонені value-імпорти з модуля `vue` (дозволені лише type-only та side-effect `import 'vue'`) і додатково сканує `.vue` SFC на імпорти Node-нативних модулів (`node:*` префікс або bare-ім’я вбудованого модуля Node — `fs`, `path`, `timers/promises` тощо). Імпорти аналізуються через **oxc-parser** (`module.staticImports`); для `.vue` вміст `<script>` витягується з SFC, далі той самий парсер (логіка в `npm/scripts/utils/vue-forbidden-imports.mjs`).
|
package/package.json
CHANGED
package/scripts/check-js-run.mjs
CHANGED
|
@@ -21,7 +21,10 @@
|
|
|
21
21
|
* - «depcheck у GitHub Actions з path-фільтром»: для кожного workflow з `paths:`,
|
|
22
22
|
* обмеженим каталогом цього пакета (`<rootDir>/...`), має бути крок
|
|
23
23
|
* `npx depcheck --ignores="graphql,bun"` (плюс інші, за потреби) з
|
|
24
|
-
* `working-directory: <rootDir>` (див. `utils/depcheck-workflow.mjs`)
|
|
24
|
+
* `working-directory: <rootDir>` (див. `utils/depcheck-workflow.mjs`);
|
|
25
|
+
* - «Паузи через setTimeout»: `new Promise(resolve => setTimeout(resolve, ms))` (з/без `await`)
|
|
26
|
+
* треба замінити на `await setTimeout(ms)` з `node:timers/promises`
|
|
27
|
+
* (див. `utils/promise-settimeout-scan.mjs`).
|
|
25
28
|
*/
|
|
26
29
|
import { existsSync } from 'node:fs'
|
|
27
30
|
import { readFile } from 'node:fs/promises'
|
|
@@ -42,6 +45,10 @@ import {
|
|
|
42
45
|
resolveConnDirFromPackageJson
|
|
43
46
|
} from './utils/conn-imports-scan.mjs'
|
|
44
47
|
import { loadCursorIgnorePaths } from './utils/load-cursor-config.mjs'
|
|
48
|
+
import {
|
|
49
|
+
findPromiseSetTimeoutInText,
|
|
50
|
+
isPromiseSetTimeoutScanSourceFile
|
|
51
|
+
} from './utils/promise-settimeout-scan.mjs'
|
|
45
52
|
import { walkDir } from './utils/walkDir.mjs'
|
|
46
53
|
import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
|
|
47
54
|
|
|
@@ -162,6 +169,30 @@ async function checkProcessEnvUsage(absPackageRoot, sourcePaths, label, fail) {
|
|
|
162
169
|
return violations
|
|
163
170
|
}
|
|
164
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Сканує джерела пакета на паттерн `new Promise(resolve => setTimeout(resolve, ms))`.
|
|
174
|
+
* @param {string} absPackageRoot абсолютний корінь пакета
|
|
175
|
+
* @param {string[]} sourcePaths абсолютні шляхи до файлів
|
|
176
|
+
* @param {string} label префікс повідомлення `[<pkg>] `
|
|
177
|
+
* @param {(msg: string) => void} fail callback при помилці
|
|
178
|
+
* @returns {Promise<number>} кількість порушень
|
|
179
|
+
*/
|
|
180
|
+
async function checkPromiseSetTimeoutPause(absPackageRoot, sourcePaths, label, fail) {
|
|
181
|
+
let violations = 0
|
|
182
|
+
for (const absPath of sourcePaths) {
|
|
183
|
+
const rel = relPosix(absPackageRoot, absPath)
|
|
184
|
+
if (!isPromiseSetTimeoutScanSourceFile(rel)) continue
|
|
185
|
+
const content = await readFile(absPath, 'utf8')
|
|
186
|
+
for (const v of findPromiseSetTimeoutInText(content, rel)) {
|
|
187
|
+
violations++
|
|
188
|
+
fail(
|
|
189
|
+
`${label}${rel}:${v.line} — заміни 'new Promise(r => setTimeout(r, ms))' на 'await setTimeout(ms)' з 'node:timers/promises': ${v.snippet}`
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return violations
|
|
194
|
+
}
|
|
195
|
+
|
|
165
196
|
/**
|
|
166
197
|
* Перевіряє відповідність правилам js-run.mdc для одного workspace-пакета.
|
|
167
198
|
* @param {string} rootDir відносний шлях workspace (не `'.'`)
|
|
@@ -205,6 +236,11 @@ async function checkWorkspacePackage(rootDir, ignorePaths, workflows, fail, pass
|
|
|
205
236
|
)
|
|
206
237
|
}
|
|
207
238
|
|
|
239
|
+
const pauseViolations = await checkPromiseSetTimeoutPause(absPackageRoot, sourcePaths, label, fail)
|
|
240
|
+
if (pauseViolations === 0) {
|
|
241
|
+
passFn(`${label}немає 'new Promise(r => setTimeout(r, ms))' — паузи через 'node:timers/promises'`)
|
|
242
|
+
}
|
|
243
|
+
|
|
208
244
|
await checkOtelConfigmap(rootDir, label, fail, passFn)
|
|
209
245
|
|
|
210
246
|
checkDepcheckInWorkflows(rootDir, workflows, label, fail, passFn)
|
package/scripts/check-vue.mjs
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* Версії Vite та плагінів, vue-macros, auto-import, layouts, вміст `vite.config`;
|
|
5
5
|
* у репозиторії — рекомендацію розширення Vue.volar.
|
|
6
6
|
*
|
|
7
|
+
* У кожному Vue+Vite-пакеті очікується `src/vite-env.d.ts` з `/// <reference types="vite/client" />`
|
|
8
|
+
* та `jsconfig.json` у корені пакета (типи для імпортів асетів у `.vue`).
|
|
9
|
+
*
|
|
7
10
|
* У `vite.config.*` заборонено використовувати `process.env.npm_lifecycle_event` (Bun не підставляє його як npm),
|
|
8
11
|
* натомість використовуй `mode` з `defineConfig(({ mode }) => ...)`.
|
|
9
12
|
*
|
|
@@ -32,6 +35,9 @@ import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
|
|
|
32
35
|
const MAJOR_VERSION_RE = /(\d+)/
|
|
33
36
|
const ESBUILD_RE = /\besbuild\b/
|
|
34
37
|
|
|
38
|
+
/** Регулярний вираз для triple-slash `reference types="vite/client"` у `src/vite-env.d.ts`. */
|
|
39
|
+
const VITE_CLIENT_REFERENCE_RE = /\/\/\/\s*<reference\s+types\s*=\s*["']vite\/client["']\s*\/>/
|
|
40
|
+
|
|
35
41
|
/**
|
|
36
42
|
* Визначає, чи можна сканувати файл як текст на згадки `esbuild`.
|
|
37
43
|
* @param {string} relPosix відносний шлях у posix-форматі
|
|
@@ -202,6 +208,43 @@ function checkRequiredDep(deps, name, prefix, passFn, fail, hint = `${name} ві
|
|
|
202
208
|
* @param {(msg: string) => void} passFn callback при успішній перевірці
|
|
203
209
|
* @param {(msg: string) => void} fail callback при помилці
|
|
204
210
|
*/
|
|
211
|
+
/**
|
|
212
|
+
* Перевіряє `src/vite-env.d.ts` і наявність `jsconfig.json` для підтягування типів асетів Vite у IDE.
|
|
213
|
+
* @param {string} rootDir відносний шлях до кореня пакета
|
|
214
|
+
* @param {string} prefix префікс повідомлень
|
|
215
|
+
* @param {(msg: string) => void} passFn успіх
|
|
216
|
+
* @param {(msg: string) => void} fail помилка
|
|
217
|
+
* @returns {Promise<void>}
|
|
218
|
+
*/
|
|
219
|
+
async function checkViteClientEnvAndEditorConfig(rootDir, prefix, passFn, fail) {
|
|
220
|
+
const envRel = join(rootDir, 'src/vite-env.d.ts')
|
|
221
|
+
if (!existsSync(envRel)) {
|
|
222
|
+
fail(
|
|
223
|
+
`${prefix}немає src/vite-env.d.ts — додай файл з рядком /// <reference types="vite/client" /> ` +
|
|
224
|
+
`(інакше TS/Volar не бачать типів для імпортів асетів: png, avif, css як URL).`
|
|
225
|
+
)
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
const envContent = await readFile(envRel, 'utf8')
|
|
229
|
+
if (!VITE_CLIENT_REFERENCE_RE.test(envContent)) {
|
|
230
|
+
fail(
|
|
231
|
+
`${prefix}src/vite-env.d.ts має містити /// <reference types="vite/client" /> ` +
|
|
232
|
+
`(без цього імпорти статичних файлів у .vue дають «Cannot find module … type declarations»).`
|
|
233
|
+
)
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
passFn(`${prefix}src/vite-env.d.ts посилається на vite/client`)
|
|
237
|
+
|
|
238
|
+
if (!existsSync(join(rootDir, 'jsconfig.json'))) {
|
|
239
|
+
fail(
|
|
240
|
+
`${prefix}немає jsconfig.json у корені пакета — додай файл з "include": ["src/**/*"] тощо, ` +
|
|
241
|
+
`щоб IDE підхопила vite-env.d.ts і .vue.`
|
|
242
|
+
)
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
passFn(`${prefix}jsconfig.json присутній`)
|
|
246
|
+
}
|
|
247
|
+
|
|
205
248
|
function checkViteVersion(devDeps, prefix, passFn, fail) {
|
|
206
249
|
const v = devDeps.vite
|
|
207
250
|
if (!v) {
|
|
@@ -442,6 +485,8 @@ async function checkVuePackage(rootDir, ignorePaths, fail, passFn) {
|
|
|
442
485
|
'vite-plugin-vue-layouts-next відсутній — bun add -d vite-plugin-vue-layouts-next'
|
|
443
486
|
)
|
|
444
487
|
|
|
488
|
+
await checkViteClientEnvAndEditorConfig(rootDir, prefix, passFn, fail)
|
|
489
|
+
|
|
445
490
|
const { hasVueAutoImport } = await checkViteConfig(rootDir, prefix, passFn, fail)
|
|
446
491
|
await checkVueImportViolations(
|
|
447
492
|
rootDir,
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Знаходить паттерн `new Promise(resolve => setTimeout(resolve, ms))` (з `await` чи без)
|
|
3
|
+
* у джерелах — таку обгортку треба замінити на `setTimeout` з `node:timers/promises`
|
|
4
|
+
* згідно з js-run.mdc, секція «Паузи через setTimeout».
|
|
5
|
+
*
|
|
6
|
+
* Семантика — структурна (без regex по тілу): `NewExpression` з ідентифікатор-callee `Promise`
|
|
7
|
+
* і єдиним аргументом-функцією, тіло якої — виклик `setTimeout(<resolve>, ms)`. Перший
|
|
8
|
+
* аргумент `setTimeout` має передавати `resolve` напряму або тривіально загорнутим у
|
|
9
|
+
* безпараметричну функцію `() => resolve()` / `function () { resolve() }` без жодних
|
|
10
|
+
* аргументів — інакше це не «чиста пауза», і паттерн не вмикається.
|
|
11
|
+
*
|
|
12
|
+
* Сканер не вимагає, щоб файл компілювався: при синтаксичних помилках повертається
|
|
13
|
+
* порожній результат (як інші сканери — спочатку треба полагодити синтаксис).
|
|
14
|
+
*/
|
|
15
|
+
import { normalizeSnippet, offsetToLine, parseProgramOrNull } from './ast-scan-utils.mjs'
|
|
16
|
+
|
|
17
|
+
const SOURCE_FILE_RE = /\.([cm]?[jt]sx?)$/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Чи аргумент, який передають у `setTimeout`, — це «голий» виклик `resolve`
|
|
21
|
+
* (тобто сам ідентифікатор або `() => resolve()` без аргументів).
|
|
22
|
+
* @param {Record<string, unknown> | null | undefined} arg AST-вузол першого аргументу `setTimeout`
|
|
23
|
+
* @param {string} paramName ім'я параметра-resolve у тіла-функції Promise
|
|
24
|
+
* @returns {boolean} `true`, якщо це чиста передача resolve без значення
|
|
25
|
+
*/
|
|
26
|
+
function isBareResolveCallback(arg, paramName) {
|
|
27
|
+
if (!arg || typeof arg !== 'object') return false
|
|
28
|
+
if (arg.type === 'Identifier' && arg.name === paramName) return true
|
|
29
|
+
if (arg.type !== 'ArrowFunctionExpression' && arg.type !== 'FunctionExpression') return false
|
|
30
|
+
if ((arg.params?.length ?? 0) !== 0) return false
|
|
31
|
+
const callExpr = extractSingleCallExpression(arg.body)
|
|
32
|
+
if (!callExpr) return false
|
|
33
|
+
if (callExpr.callee?.type !== 'Identifier' || callExpr.callee.name !== paramName) return false
|
|
34
|
+
return !Array.isArray(callExpr.arguments) || callExpr.arguments.length === 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Якщо тіло функції — рівно один `CallExpression` (концизне `() => foo()` або
|
|
39
|
+
* `{ foo() }` без інших стейтментів), повертає його. Інакше — `null`.
|
|
40
|
+
* @param {unknown} body тіло функції з AST
|
|
41
|
+
* @returns {Record<string, unknown> | null} AST-вузол `CallExpression` або `null`
|
|
42
|
+
*/
|
|
43
|
+
function extractSingleCallExpression(body) {
|
|
44
|
+
if (!body || typeof body !== 'object') return null
|
|
45
|
+
if (body.type === 'CallExpression') return body
|
|
46
|
+
if (body.type !== 'BlockStatement') return null
|
|
47
|
+
if (!Array.isArray(body.body) || body.body.length !== 1) return null
|
|
48
|
+
const stmt = body.body[0]
|
|
49
|
+
if (!stmt || stmt.type !== 'ExpressionStatement') return null
|
|
50
|
+
const expr = stmt.expression
|
|
51
|
+
return expr?.type === 'CallExpression' ? expr : null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Чи це `NewExpression` виду `new Promise(<resolve> => setTimeout(<resolve>, ms))`.
|
|
56
|
+
* Параметр-resolve має бути простим Identifier; setTimeout — глобальним викликом
|
|
57
|
+
* за іменем (з будь-якого джерела — node:timers, global, тощо: значення для нас має
|
|
58
|
+
* лише структурний паттерн).
|
|
59
|
+
* @param {Record<string, unknown> | null | undefined} node AST-вузол
|
|
60
|
+
* @returns {boolean} `true`, якщо це проблемний паттерн «обгортки таймера у Promise»
|
|
61
|
+
*/
|
|
62
|
+
function isPromiseSetTimeoutDelay(node) {
|
|
63
|
+
if (!node || node.type !== 'NewExpression') return false
|
|
64
|
+
if (node.callee?.type !== 'Identifier' || node.callee.name !== 'Promise') return false
|
|
65
|
+
if (!Array.isArray(node.arguments) || node.arguments.length !== 1) return false
|
|
66
|
+
const fn = node.arguments[0]
|
|
67
|
+
if (!fn || (fn.type !== 'ArrowFunctionExpression' && fn.type !== 'FunctionExpression')) return false
|
|
68
|
+
if (!Array.isArray(fn.params) || fn.params.length === 0) return false
|
|
69
|
+
const firstParam = fn.params[0]
|
|
70
|
+
if (!firstParam || firstParam.type !== 'Identifier') return false
|
|
71
|
+
const setTimeoutCall = extractSingleCallExpression(fn.body)
|
|
72
|
+
if (!setTimeoutCall) return false
|
|
73
|
+
if (setTimeoutCall.callee?.type !== 'Identifier' || setTimeoutCall.callee.name !== 'setTimeout') return false
|
|
74
|
+
if (!Array.isArray(setTimeoutCall.arguments) || setTimeoutCall.arguments.length < 1) return false
|
|
75
|
+
return isBareResolveCallback(setTimeoutCall.arguments[0], firstParam.name)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Простий рекурсивний обхід AST: заходимо в усі об'єкти/масиви, щоб знайти `NewExpression`.
|
|
80
|
+
* @param {unknown} node корінь або під-вузол AST
|
|
81
|
+
* @param {(n: Record<string, unknown>) => void} visit виклик для кожного об'єкта-вузла з `type`
|
|
82
|
+
* @returns {void}
|
|
83
|
+
*/
|
|
84
|
+
function walkAst(node, visit) {
|
|
85
|
+
if (!node || typeof node !== 'object') return
|
|
86
|
+
if (Array.isArray(node)) {
|
|
87
|
+
for (const item of node) walkAst(item, visit)
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
if (typeof node.type === 'string') {
|
|
91
|
+
visit(node)
|
|
92
|
+
}
|
|
93
|
+
for (const key of Object.keys(node)) {
|
|
94
|
+
if (key === 'parent') continue
|
|
95
|
+
const v = node[key]
|
|
96
|
+
if (v && typeof v === 'object') walkAst(v, visit)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Знаходить усі `new Promise(resolve => setTimeout(resolve, ms))` у тексті.
|
|
102
|
+
* @param {string} content вихідний код
|
|
103
|
+
* @param {string} [virtualPath] шлях для вибору `lang` (наприклад `pkg/src/foo.ts`)
|
|
104
|
+
* @returns {{ line: number, snippet: string }[]} список порушень
|
|
105
|
+
*/
|
|
106
|
+
export function findPromiseSetTimeoutInText(content, virtualPath = 'scan.ts') {
|
|
107
|
+
const program = parseProgramOrNull(content, virtualPath)
|
|
108
|
+
if (!program) return []
|
|
109
|
+
/** @type {{ line: number, snippet: string }[]} */
|
|
110
|
+
const out = []
|
|
111
|
+
walkAst(program, node => {
|
|
112
|
+
if (!isPromiseSetTimeoutDelay(node)) return
|
|
113
|
+
out.push({
|
|
114
|
+
line: offsetToLine(content, node.start),
|
|
115
|
+
snippet: normalizeSnippet(content.slice(node.start, node.end))
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Чи сканувати цей файл за розширенням (JS/TS-сім'я, виключно з `.d.ts`).
|
|
123
|
+
* @param {string} relativePath відносний шлях до файлу
|
|
124
|
+
* @returns {boolean} `true`, якщо розширення підходить для сканування
|
|
125
|
+
*/
|
|
126
|
+
export function isPromiseSetTimeoutScanSourceFile(relativePath) {
|
|
127
|
+
if (!SOURCE_FILE_RE.test(relativePath)) return false
|
|
128
|
+
return !relativePath.endsWith('.d.ts')
|
|
129
|
+
}
|