@nitra/cursor 1.8.184 → 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 CHANGED
@@ -4,6 +4,42 @@
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
+
35
+ ## [1.8.185] - 2026-05-06
36
+
37
+ ### Changed
38
+
39
+ - `image` (mdc v1.4 → v1.5): прапорець `--avif` у `lint-image` тепер **заборонений** (інакше `bun run lint` плодив би `.avif` для зображень, що ніде не вживаються); канонічний `lint-image` — `npx @nitra/minify-image --src=. --write`. AVIF-генерацію виконує **виключно** `npx @nitra/cursor check image`. Секцію «AVIF-імпорти у `.vue`» переписано: тепер вона документує триетапну логіку `check image` — (1) запуск `npx @nitra/minify-image --src=. --write --avif`, (2) авто-заміна raster-посилань у `.vue`/`.html` на `.avif` у кожному workspace-пакеті, (3) прибирання AVIF-сиріт (файли `.avif` без жодного посилання у `.vue`/`.html` видаляються — AVIF лишається лише там, де заміна реально вдалася).
40
+ - `check-image.mjs`: `checkLintImageScript` більше не вимагає `--avif`, натомість фейлить за його наявністю; додано `runAvifGeneration` (best-effort `npx ... --avif`, опт-аут через `NITRA_CURSOR_NO_AVIF_RUN=1` для тестів), `cleanupOrphanAvifs` (видаляє `<...>.avif` без живого посилання), `hasAnyRasterImage`, `resolveImagePath`. `checkVueAvifImportsInPackage` тепер не лише валідує, а й переписує raster-посилання на `.avif` (коли AVIF-двійник реально існує на диску); якщо `.avif` нема — фейл, як раніше. Сканування поширено на `.html` файли (раніше було тільки `.vue`).
41
+ - `tests/check-image.test.mjs`: `CANONICAL_LINT_IMAGE` без `--avif`; кейс «без `--avif`» перейменовано/перекинуто на «з забороненим `--avif`»; додано тести на orphan-cleanup (`.avif` без посилань видаляється) та авто-заміну raster-імпорту, коли `.avif`-сусід реально існує.
42
+
7
43
  ## [1.8.184] - 2026-05-06
8
44
 
9
45
  ### Added
package/mdc/image.mdc CHANGED
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  description: Оптимізація зображень через @nitra/minify-image у локальному lint
3
3
  alwaysApply: true
4
- version: '1.4'
4
+ version: '1.5'
5
5
  ---
6
6
 
7
- CLI [`@nitra/minify-image`](https://www.npmjs.com/package/@nitra/minify-image) (≥ **3.2.0**) запускається через `npx` (як `markdownlint-cli2` у text.mdc) і **не** додається в `dependencies` / `devDependencies`. Канонічний `lint-image` — авто-оптимізація з прапорцями `--write --avif`: стискає raster/SVG на місці й створює AVIF-двійники (`<name>.<ext>.avif`) поряд з кожним PNG/JPEG/GIF. Split-cache робить повторні прогони дешевими — і локально, і після `git clone`.
7
+ CLI [`@nitra/minify-image`](https://www.npmjs.com/package/@nitra/minify-image) (≥ **3.2.0**) запускається через `npx` (як `markdownlint-cli2` у text.mdc) і **не** додається в `dependencies` / `devDependencies`. Канонічний `lint-image` — авто-оптимізація з прапорцем `--write`: стискає raster/SVG на місці. **AVIF-генерація (`--avif`) у `lint-image` заборонена** — її виконує лише `npx @nitra/cursor check image`, який заодно прибирає AVIF-сироти (див. секцію «AVIF-імпорти у `.vue`»). Split-cache робить повторні прогони дешевими — і локально, і після `git clone`.
8
8
 
9
9
  Перевірка лише локальна — у CI `lint-image` не запускаємо (sharp/svgo тягнуть бінарні залежності, цінність на ubuntu-runner-ах нижча за час прогону). Окремий workflow `lint-image.yml` створювати не треба.
10
10
 
@@ -14,12 +14,12 @@ CLI [`@nitra/minify-image`](https://www.npmjs.com/package/@nitra/minify-image) (
14
14
  {
15
15
  "scripts": {
16
16
  "lint": "bun run lint-js && bun run lint-text && bun run lint-ga && bun run lint-image && oxfmt .",
17
- "lint-image": "npx @nitra/minify-image --src=. --write --avif"
17
+ "lint-image": "npx @nitra/minify-image --src=. --write"
18
18
  }
19
19
  }
20
20
  ```
21
21
 
22
- Якщо в `package.json` уже є агрегований `lint`, додай у його ланцюжок `bun run lint-image` (як `bun run lint-text`, `bun run lint-js`, `bun run lint-ga`). Так розробник, що локально гонить `bun run lint`, перед фіксацією одразу бачить, чи зросли зображення, і отримує свіжі AVIF-двійники.
22
+ Якщо в `package.json` уже є агрегований `lint`, додай у його ланцюжок `bun run lint-image` (як `bun run lint-text`, `bun run lint-js`, `bun run lint-ga`). Так розробник, що локально гонить `bun run lint`, перед фіксацією одразу бачить, чи зросли зображення.
23
23
 
24
24
  ## Split-cache
25
25
 
@@ -47,13 +47,19 @@ rm -f .minify-image-cache.tsv
47
47
  # прибери відповідний рядок з .gitignore, якщо був
48
48
  ```
49
49
 
50
- AVIF-двійники (`<name>.<ext>.avif`) **зберігаємо в git** — це готові артефакти для віддачі браузеру (без них ефект від `--avif` втрачається на чистому checkout-і).
50
+ AVIF-двійники (`<name>.<ext>.avif`) **зберігаємо в git** — це готові артефакти для віддачі браузеру (без них ефект від AVIF втрачається на чистому checkout-і).
51
51
 
52
52
  ## AVIF-імпорти у `.vue`
53
53
 
54
- Раз `--avif` гарантує `<name>.<ext>.avif` поряд з кожним PNG/JPEG/GIF, у `.vue` файлах потрібно посилатись саме на AVIF-двійник, а не на оригінал:
54
+ AVIF-двійники (`<name>.<ext>.avif`) генерує **виключно** `npx @nitra/cursor check image` — у `lint-image` прапорець `--avif` заборонений (інакше `bun run lint` плодить непотрібні `.avif` для зображень, що ніде не використовуються). Перевірка робить три кроки в порядку:
55
55
 
56
- ```vue title="App.vue (правильно)"
56
+ 1. Запускає `npx @nitra/minify-image --src=. --write --avif` — генерує `<name>.<ext>.avif` поряд з кожним PNG/JPEG/GIF.
57
+ 2. Сканує `.vue` (а також `.html`) файли в кожному workspace-пакеті (root + workspaces) і автоматично переписує raster-посилання на AVIF-двійник у двох формах:
58
+ - **Імпорт-пов'язані** — `import x from '...png|jpg|jpeg|gif'` (далі `:src="x"` у шаблоні);
59
+ - **Прямі статичні** — `<img src="...png" />` у `<template>` (Vite перетворює такий шлях на asset-імпорт на етапі збірки, тож вимога та сама).
60
+ 3. Видаляє AVIF-сироти: ходить по всіх `<...>.avif` у репозиторії; якщо на двійник не лишилось жодного посилання у `.vue`/`.html` — файл видаляється. **AVIF на диску лишається лише там, де заміна реально відбулась** — тому невикористані оригінали не накопичують `.avif`-«хвости».
61
+
62
+ ```vue title="App.vue (після check image)"
57
63
  <script setup>
58
64
  import welcomeImage from './assets/welcome.png.avif'
59
65
  </script>
@@ -63,19 +69,10 @@ import welcomeImage from './assets/welcome.png.avif'
63
69
  </template>
64
70
  ```
65
71
 
66
- ```vue title="App.vue (неправильно — втрачає AVIF)"
67
- <script setup>
68
- import welcomeImage from './assets/welcome.png'
69
- </script>
70
- ```
71
-
72
- Перевірка `check image` сканує `.vue` файли в кожному workspace-пакеті (root + workspaces) і вимагає AVIF-двійник для двох форм:
73
-
74
- 1. **Імпорт-пов'язані** — `import x from '...png|jpg|jpeg|gif'` (далі `:src="x"` у шаблоні).
75
- 2. **Прямі статичні** — `<img src="...png" />` у `<template>` (Vite перетворює такий шлях на asset-імпорт на етапі збірки, тож вимога та сама).
76
-
77
72
  Реактивне `:src="..."` (з JS-виразом — змінною, тернарником, викликом тощо) **не сканується** — значення обчислюється у рантаймі й шлях туди потрапляє через імпорт або інший резолвинг, який ловить імпорт-перевірка вище. SVG не торкаємо (vector → AVIF безглуздо). Атрибути `data-src=`, `obj.src=` у `<script>` тощо також пропускаються.
78
73
 
74
+ Якщо raster-посилання у `.vue`/`.html` не вдалось переписати (наприклад, оригіналу немає на диску, тож і `.avif` не згенерувався) — `check image` падає з помилкою на конкретний файл, як раніше.
75
+
79
76
  ### Опт-аут для конкретного пакета
80
77
 
81
78
  У workspace-пакеті, де AVIF-імпорти небажані (наприклад, мобільний бандл, де AVIF-підтримка не гарантована), додай у `package.json` цього пакета:
@@ -92,8 +89,8 @@ import welcomeImage from './assets/welcome.png'
92
89
 
93
90
  ## Заборонені залежності
94
91
 
95
- `@nitra/minify-image` не повинен зʼявлятися ні в `dependencies`, ні в `devDependencies` кореневого `package.json` — CLI запускається лише через `npx` (так само, як `markdownlint-cli2` у text.mdc). Якщо потрібен явний пін — закладай діапазон версій npm у самому виклику (`npx @nitra/minify-image@^3 --src=. --write --avif`).
92
+ `@nitra/minify-image` не повинен зʼявлятися ні в `dependencies`, ні в `devDependencies` кореневого `package.json` — CLI запускається лише через `npx` (так само, як `markdownlint-cli2` у text.mdc). Якщо потрібен явний пін — закладай діапазон версій npm у самому виклику (`npx @nitra/minify-image@^3 --src=. --write`).
96
93
 
97
94
  ## Перевірка
98
95
 
99
- `npx @nitra/cursor check image` (охоплює `lint-image` з обовʼязковими `--src=.`, `--write`, `--avif`, агрегований `lint`, `.n-minify-image.tsv` НЕ в `.gitignore` (має бути в git), відсутність застарілого `.minify-image-cache.tsv` у корені, AVIF-імпорти у `.vue` файлах кожного workspace-пакета).
96
+ `npx @nitra/cursor check image` (охоплює `lint-image` з обовʼязковими `--src=.`, `--write` і **забороненим** `--avif`; агрегований `lint`; `.n-minify-image.tsv` НЕ в `.gitignore` має бути в git; відсутність застарілого `.minify-image-cache.tsv` у корені; запуск AVIF-генерації + авто-заміна raster-посилань на `.avif` у `.vue`/`.html` кожного workspace-пакета + прибирання AVIF-сиріт).
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.3'
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.5'
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`, а також обходить джерела 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`).
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.184",
3
+ "version": "1.8.188",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -4,7 +4,9 @@
4
4
  *
5
5
  * Очікування:
6
6
  * - у кореневому `package.json` є скрипт `lint-image`, який викликає `npx @nitra/minify-image`
7
- * з обовʼязковими `--src=.`, `--write` і `--avif` (авто-оптимізація з AVIF-двійниками);
7
+ * з обовʼязковими `--src=.` і `--write`. Прапорець `--avif` у `lint-image` заборонений —
8
+ * AVIF-генерацію виконує `check image` (інакше `bun run lint` плодив би `.avif` для
9
+ * зображень, що ніде не вживаються);
8
10
  * - якщо в `package.json` є агрегований скрипт `lint`, він викликає `bun run lint-image`
9
11
  * (симетрично до `lint-text`, `lint-js`, `lint-ga`);
10
12
  * - `@nitra/minify-image` не оголошений у `dependencies`/`devDependencies` —
@@ -14,15 +16,25 @@
14
16
  * `node_modules/.cache/@nitra/minify-image/mtime.tsv` авто-gitignored через `node_modules/`,
15
17
  * окремої перевірки не вимагає;
16
18
  * - застарілий `.minify-image-cache.tsv` (з версій < 3.2) видалений з кореня — інакше
17
- * проєкт лишається у напівпереміщеному стані;
18
- * - у `.vue` файлах raster-імпорти (`.png` / `.jpg` / `.jpeg` / `.gif`) посилаються на
19
- * AVIF-двійники (`...png.avif` тощо), оскільки `--avif` гарантує їх наявність поряд із
20
- * оригіналами. Можна вимкнути на рівні воркспейс-пакета через `"@nitra/minify-image": {
21
- * "disable-avif": true }` у його `package.json`.
19
+ * проєкт лишається у напівпереміщеному стані.
20
+ *
21
+ * Дії під час `check image` (на додачу до валідацій):
22
+ * 1. `npx @nitra/minify-image --src=. --write --avif` — генерує AVIF-двійники.
23
+ * 2. У кожному workspace-пакеті переписує raster-посилання у `.vue`/`.html` на `.avif`
24
+ * (де AVIF-двійник реально існує на диску). Pakety з `"@nitra/minify-image": {
25
+ * "disable-avif": true }` у `package.json` пропускаються.
26
+ * 3. Прибирає AVIF-сироти — `<name>.<ext>.avif`, на які не лишилось жодного посилання
27
+ * у `.vue`/`.html` репозиторію, видаляються (умова правила: «AVIF лишається лише
28
+ * там, де заміна вдалася»).
29
+ *
30
+ * Якщо raster-посилання у `.vue`/`.html` не вдалось переписати (наприклад, оригіналу
31
+ * нема на диску → `.avif` теж не згенерувався) — фейл на конкретний файл, як раніше.
22
32
  */
23
33
  import { existsSync } from 'node:fs'
24
- import { readFile } from 'node:fs/promises'
34
+ import { readFile, unlink, writeFile } from 'node:fs/promises'
25
35
  import { join, relative } from 'node:path'
36
+ import { spawnSync } from 'node:child_process'
37
+ import { env } from 'node:process'
26
38
 
27
39
  import { createCheckReporter } from './utils/check-reporter.mjs'
28
40
  import { loadCursorIgnorePaths } from './utils/load-cursor-config.mjs'
@@ -58,19 +70,28 @@ const VUE_RASTER_IMPORT_RE = /import\s+\w[\w$]*\s+from\s+['"]([^'"\n]+\.(?:png|j
58
70
  */
59
71
  const VUE_RASTER_STATIC_SRC_RE = /(?<![:\-_.])\bsrc\s*=\s*['"]([^'"\s]+\.(?:png|jpe?g|gif))['"]/giu
60
72
 
73
+ /**
74
+ * Регексп для готових AVIF-посилань у `.vue`/`.html` (як `import x from '...png.avif'`,
75
+ * так і `<img src="....png.avif" />`). Потрібен лише для збору множини «живих» AVIF —
76
+ * щоб після авто-заміни знати, які `<...>.avif` файли ще на щось посилаються, а які
77
+ * є сиротами і підлягають видаленню.
78
+ */
79
+ const VUE_AVIF_REF_RE = /['"]([^'"\s]+\.(?:png|jpe?g|gif)\.avif)['"]/giu
80
+
61
81
  /**
62
82
  * Перевіряє скрипт `lint-image` у `package.json`.
63
83
  *
64
- * Має містити виклик `npx @nitra/minify-image` з обовʼязковими прапорцями `--src=.`,
65
- * `--write` (авто-оптимізація на місці) і `--avif` (AVIF-двійники для PNG/JPEG/GIF).
66
- * Без `--write`/`--avif` лінт лише оцінює економію для проєктних коммітів цього мало.
84
+ * Має містити виклик `npx @nitra/minify-image` з обовʼязковими прапорцями `--src=.`
85
+ * і `--write` (авто-оптимізація на місці). Прапорець `--avif` у `lint-image`
86
+ * заборонений AVIF-генерацію виконує `check image`, інакше `bun run lint` плодить
87
+ * `.avif` для зображень, що ніде не вживаються.
67
88
  * @param {string|undefined} lintImage значення `scripts['lint-image']`
68
89
  * @param {(msg: string) => void} pass callback при успішній перевірці
69
90
  * @param {(msg: string) => void} fail callback при помилці
70
91
  * @returns {void}
71
92
  */
72
93
  function checkLintImageScript(lintImage, pass, fail) {
73
- const canonical = `npx ${MINIFY_PACKAGE_NAME} --src=. --write --avif`
94
+ const canonical = `npx ${MINIFY_PACKAGE_NAME} --src=. --write`
74
95
  if (typeof lintImage !== 'string' || !lintImage.trim()) {
75
96
  fail(`package.json: додай скрипт "lint-image" з \`${canonical}\` (image.mdc)`)
76
97
  return
@@ -82,8 +103,7 @@ function checkLintImageScript(lintImage, pass, fail) {
82
103
  /** @type {{ flag: string, variants: string[], hint: string }[]} */
83
104
  const requiredFlags = [
84
105
  { flag: '--src=.', variants: ['--src=.', '--src .'], hint: '`--src=.`' },
85
- { flag: '--write', variants: ['--write'], hint: '`--write` (авто-оптимізація на місці)' },
86
- { flag: '--avif', variants: ['--avif'], hint: '`--avif` (AVIF-двійники для PNG/JPEG/GIF)' }
106
+ { flag: '--write', variants: ['--write'], hint: '`--write` (авто-оптимізація на місці)' }
87
107
  ]
88
108
  const missing = requiredFlags.filter(f => !f.variants.some(v => lintImage.includes(v)))
89
109
  if (missing.length > 0) {
@@ -92,6 +112,12 @@ function checkLintImageScript(lintImage, pass, fail) {
92
112
  )
93
113
  return
94
114
  }
115
+ if (lintImage.includes('--avif')) {
116
+ fail(
117
+ `package.json: прибери \`--avif\` з lint-image — AVIF-генерацію виконує \`npx @nitra/cursor check image\` (image.mdc). Канонічний виклик: \`${canonical}\``
118
+ )
119
+ return
120
+ }
95
121
  pass(`package.json: lint-image викликає \`${canonical}\``)
96
122
  }
97
123
 
@@ -207,56 +233,111 @@ function packageHasAvifDisabled(pkg) {
207
233
  }
208
234
 
209
235
  /**
210
- * Сканує `.vue` файли одного workspace-пакета на raster-імпорти, що ще не використовують `.avif`.
236
+ * Резолвить шлях зображення з імпорта/атрибуту відносно файла, що його містить, до абсолютного
237
+ * шляху файла на диску. Шляхи, що не починаються з `.` чи `/`, не резолвимо (alias-resolver
238
+ * Vite/тощо невідомий тут — залишаємо такі посилання як є).
239
+ * @param {string} importPath шлях у `import x from '...'` або `src="..."`
240
+ * @param {string} sourceAbsPath абсолютний шлях файла з посиланням
241
+ * @returns {string|null} абсолютний шлях зображення або `null`, якщо резолвити не можемо
242
+ */
243
+ function resolveImagePath(importPath, sourceAbsPath) {
244
+ if (importPath.startsWith('.')) {
245
+ return join(sourceAbsPath, '..', importPath)
246
+ }
247
+ if (importPath.startsWith('/')) {
248
+ return join(process.cwd(), importPath)
249
+ }
250
+ return null
251
+ }
252
+
253
+ /**
254
+ * Сканує `.vue` і `.html` файли одного workspace-пакета: де можемо, переписує raster-посилання
255
+ * на `<path>.avif`, де не можемо — фейлимо. Повертає множину `.avif`-двійників, на які
256
+ * лишилось живе посилання після проходу — потрібно для подальшого прибирання сиріт.
257
+ *
258
+ * Заміна виконується ТІЛЬКИ якщо AVIF-двійник реально існує на диску. Якщо AVIF немає
259
+ * (наприклад, оригіналу теж немає, тож `--avif` його не згенерував) — фейл, як раніше.
211
260
  *
212
261
  * Файли, що належать іншим workspace-пакетам, ігноруються — кожен пакет перевіряється рівно
213
262
  * один раз (інакше при обході кореня `.` ми б повторно зайшли в `demo/` і подвоїли звіти).
214
263
  * @param {string} packageRoot відносний шлях до кореня пакета (наприклад `'.'` або `'demo'`)
215
264
  * @param {string[]} otherRootsAbs абсолютні шляхи інших workspace-коренів — їх піддерева пропускаємо
216
265
  * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
266
+ * @param {Set<string>} usedAvifAbs мутабельна множина абсолютних шляхів `.avif`, що мають
267
+ * хоч одне посилання у `.vue`/`.html` (доповнюється у цій функції)
217
268
  * @param {(msg: string) => void} pass callback при успішній перевірці
218
269
  * @param {(msg: string) => void} fail callback при помилці
219
270
  * @returns {Promise<void>} резолвиться по завершенню перевірки одного пакета
220
271
  */
221
- async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePaths, pass, fail) {
272
+ async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePaths, usedAvifAbs, pass, fail) {
222
273
  const absRoot = join(process.cwd(), packageRoot)
223
274
  const label = packageRoot === '.' ? 'корінь' : packageRoot
224
275
  /** @type {string[]} */
225
- const vueFiles = []
276
+ const targetFiles = []
226
277
  await walkDir(
227
278
  absRoot,
228
279
  absPath => {
229
- if (!absPath.endsWith('.vue')) return
280
+ if (!absPath.endsWith('.vue') && !absPath.endsWith('.html')) return
230
281
  if (otherRootsAbs.some(other => absPath.startsWith(`${other}/`))) return
231
- vueFiles.push(absPath)
282
+ targetFiles.push(absPath)
232
283
  },
233
284
  ignorePaths
234
285
  )
235
- if (vueFiles.length === 0) return
286
+ if (targetFiles.length === 0) return
236
287
 
237
288
  let violations = 0
238
- for (const absPath of vueFiles) {
289
+ let replacements = 0
290
+ for (const absPath of targetFiles) {
239
291
  const rel = relative(process.cwd(), absPath).split('\\').join('/')
240
- const content = await readFile(absPath, 'utf8')
241
- for (const match of content.matchAll(VUE_RASTER_IMPORT_RE)) {
242
- violations++
243
- const importPath = match[1]
244
- fail(
245
- `[${label}] ${rel}: import з '${importPath}' має посилатись на AVIF-двійник '${importPath}.avif' ` +
246
- `(lint-image --avif створює його поряд). Вимкнути локально: "@nitra/minify-image": { "disable-avif": true } у package.json пакета`
247
- )
292
+ const original = await readFile(absPath, 'utf8')
293
+ let updated = original
294
+
295
+ /**
296
+ * @param {RegExp} regex з групою 1 = шлях до зображення
297
+ * @param {(srcPath: string) => string} renderFailure повідомлення помилки
298
+ */
299
+ const processMatches = (regex, renderFailure) => {
300
+ updated = updated.replaceAll(regex, (full, importPath) => {
301
+ const newImportPath = `${importPath}.avif`
302
+ const replaced = full.replace(importPath, newImportPath)
303
+ const imageAbs = resolveImagePath(importPath, absPath)
304
+ if (imageAbs && existsSync(`${imageAbs}.avif`)) {
305
+ replacements++
306
+ usedAvifAbs.add(`${imageAbs}.avif`)
307
+ return replaced
308
+ }
309
+ violations++
310
+ fail(renderFailure(importPath))
311
+ return full
312
+ })
248
313
  }
249
- for (const match of content.matchAll(VUE_RASTER_STATIC_SRC_RE)) {
250
- violations++
251
- const srcPath = match[1]
252
- fail(
314
+
315
+ processMatches(
316
+ VUE_RASTER_IMPORT_RE,
317
+ importPath =>
318
+ `[${label}] ${rel}: import з '${importPath}' має посилатись на AVIF-двійник '${importPath}.avif' ` +
319
+ `(\`npx @nitra/cursor check image\` створює його поряд, якщо оригінал є на диску). Вимкнути локально: "@nitra/minify-image": { "disable-avif": true } у package.json пакета`
320
+ )
321
+ processMatches(
322
+ VUE_RASTER_STATIC_SRC_RE,
323
+ srcPath =>
253
324
  `[${label}] ${rel}: пряме \`src="${srcPath}"\` у шаблоні має використовувати AVIF-двійник \`src="${srcPath}.avif"\` ` +
254
- `(або винеси у import + \`:src="..."\`). Вимкнути локально: "@nitra/minify-image": { "disable-avif": true } у package.json пакета`
255
- )
325
+ `(або винеси у import + \`:src="..."\`). Вимкнути локально: "@nitra/minify-image": { "disable-avif": true } у package.json пакета`
326
+ )
327
+
328
+ for (const match of updated.matchAll(VUE_AVIF_REF_RE)) {
329
+ const avifPath = match[1]
330
+ const avifAbs = resolveImagePath(avifPath, absPath)
331
+ if (avifAbs) usedAvifAbs.add(avifAbs)
332
+ }
333
+
334
+ if (updated !== original) {
335
+ await writeFile(absPath, updated, 'utf8')
256
336
  }
257
337
  }
258
338
  if (violations === 0) {
259
- pass(`[${label}] усі raster-посилання у .vue вже на .avif (або відсутні)`)
339
+ const summary = replacements > 0 ? `усі raster-посилання у .vue/.html переписано на .avif (замін: ${replacements})` : 'усі raster-посилання у .vue/.html вже на .avif (або відсутні)'
340
+ pass(`[${label}] ${summary}`)
260
341
  }
261
342
  }
262
343
 
@@ -265,11 +346,13 @@ async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePa
265
346
  * перевірку Vue-imports. Перевірка пропускається, якщо в репозиторії немає workspaces
266
347
  * або немає `.vue`-файлів — тоді `image` правило не для цього проєкту.
267
348
  * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
349
+ * @param {Set<string>} usedAvifAbs мутабельна множина абсолютних шляхів `.avif`-двійників,
350
+ * на які лишилось хоча б одне посилання у `.vue`/`.html` (заповнюється у викликаних функціях)
268
351
  * @param {(msg: string) => void} pass callback при успішній перевірці
269
352
  * @param {(msg: string) => void} fail callback при помилці
270
353
  * @returns {Promise<void>} резолвиться по завершенню перевірки всіх workspace-пакетів
271
354
  */
272
- async function checkVueAvifImports(ignorePaths, pass, fail) {
355
+ async function checkVueAvifImports(ignorePaths, usedAvifAbs, pass, fail) {
273
356
  const roots = await getMonorepoPackageRootDirs()
274
357
  const absRootsByRel = new Map(roots.map(r => [r, join(process.cwd(), r)]))
275
358
  for (const root of roots) {
@@ -283,7 +366,85 @@ async function checkVueAvifImports(ignorePaths, pass, fail) {
283
366
  continue
284
367
  }
285
368
  const otherRootsAbs = roots.filter(r => r !== root && r !== '.').map(r => absRootsByRel.get(r) ?? '')
286
- await checkVueAvifImportsInPackage(root, otherRootsAbs, ignorePaths, pass, fail)
369
+ await checkVueAvifImportsInPackage(root, otherRootsAbs, ignorePaths, usedAvifAbs, pass, fail)
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Чи є в репозиторії хоч один raster-файл, який мав би сенс конвертувати у AVIF.
375
+ * Якщо немає — `npx @nitra/minify-image` нема що робити, тож зайвий запуск пропускаємо
376
+ * (важливо у тестах: фікстурні `.png`-імпорти посилаються на неіснуючі файли, тож
377
+ * minify-image все одно нічого не згенерує — а зайвий npx-спавн повільний і робить шум).
378
+ * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
379
+ * @returns {Promise<boolean>} `true`, якщо знайдено принаймні один `.png/.jpe?g/.gif`
380
+ */
381
+ async function hasAnyRasterImage(ignorePaths) {
382
+ let found = false
383
+ await walkDir(
384
+ process.cwd(),
385
+ absPath => {
386
+ if (found) return
387
+ if (/\.(?:png|jpe?g|gif)$/iu.test(absPath)) found = true
388
+ },
389
+ ignorePaths
390
+ )
391
+ return found
392
+ }
393
+
394
+ /**
395
+ * Запускає `npx @nitra/minify-image --src=. --write --avif` для генерації AVIF-двійників.
396
+ *
397
+ * Виклик best-effort: якщо мережа/кеш недоступні чи бінарника нема — лог-варн без падіння
398
+ * перевірки (валідації package.json і vue-refs все одно прогоняться, vue-refs на
399
+ * відсутні `.avif` фейлять окремо). У тестах та інших ізольованих середовищах npx
400
+ * можна вимкнути через `NITRA_CURSOR_NO_AVIF_RUN=1` — тоді ця функція no-op.
401
+ * @returns {void}
402
+ */
403
+ function runAvifGeneration() {
404
+ if (env.NITRA_CURSOR_NO_AVIF_RUN === '1') return
405
+ const result = spawnSync('npx', [MINIFY_PACKAGE_NAME, '--src=.', '--write', '--avif'], {
406
+ stdio: 'inherit',
407
+ env
408
+ })
409
+ if (result.error) {
410
+ console.log(
411
+ ` ⚠️ не вдалося запустити \`npx ${MINIFY_PACKAGE_NAME} --avif\`: ${result.error.message} — vue/html-перевірка покаже файли, для яких не вистачає .avif`
412
+ )
413
+ return
414
+ }
415
+ if (typeof result.status === 'number' && result.status !== 0) {
416
+ console.log(
417
+ ` ⚠️ \`npx ${MINIFY_PACKAGE_NAME} --avif\` завершився з кодом ${result.status} — vue/html-перевірка покаже файли, для яких не вистачає .avif`
418
+ )
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Видаляє AVIF-сироти — `<...>.avif` файли, на які не лишилось жодного посилання
424
+ * у `.vue`/`.html` репозиторію. Реалізує умову правила: «AVIF лишається лише там,
425
+ * де заміна реально вдалася».
426
+ * @param {Set<string>} usedAvifAbs абсолютні шляхи `.avif`, що мають живі посилання
427
+ * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
428
+ * @param {(msg: string) => void} pass callback при успішній перевірці
429
+ * @returns {Promise<void>} резолвиться після видалення всіх сиріт
430
+ */
431
+ async function cleanupOrphanAvifs(usedAvifAbs, ignorePaths, pass) {
432
+ /** @type {string[]} */
433
+ const orphans = []
434
+ await walkDir(
435
+ process.cwd(),
436
+ absPath => {
437
+ if (!absPath.endsWith('.avif')) return
438
+ if (usedAvifAbs.has(absPath)) return
439
+ orphans.push(absPath)
440
+ },
441
+ ignorePaths
442
+ )
443
+ for (const absPath of orphans) {
444
+ await unlink(absPath)
445
+ }
446
+ if (orphans.length > 0) {
447
+ pass(`видалено AVIF-сиріт без посилань у .vue/.html: ${orphans.length}`)
287
448
  }
288
449
  }
289
450
 
@@ -308,9 +469,10 @@ async function checkPackageJsonImage(pass, fail) {
308
469
 
309
470
  /**
310
471
  * Перевіряє відповідність проєкту правилам `image.mdc` (split-cache 3.2.0):
311
- * `lint-image` через `npx @nitra/minify-image --src=. --write --avif`, агрегований `lint`,
472
+ * `lint-image` через `npx @nitra/minify-image --src=. --write` (без `--avif`!), агрегований `lint`,
312
473
  * `.n-minify-image.tsv` НЕ в `.gitignore` (committed source of truth), застарілий
313
- * `.minify-image-cache.tsv` видалений, AVIF-імпорти у `.vue` файлах. CI-workflow
474
+ * `.minify-image-cache.tsv` видалений. Окремо виконуються дії: запуск AVIF-генерації,
475
+ * авто-заміна raster-посилань у `.vue`/`.html`, видалення AVIF-сиріт. CI-workflow
314
476
  * для image не вимагається — лінт зображень виконується лише локально.
315
477
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
316
478
  */
@@ -324,7 +486,15 @@ export async function check() {
324
486
  await checkLegacyCacheRemoved(pass, fail)
325
487
  }
326
488
  const ignorePaths = await loadCursorIgnorePaths(process.cwd())
327
- await checkVueAvifImports(ignorePaths, pass, fail)
489
+
490
+ if (await hasAnyRasterImage(ignorePaths)) {
491
+ runAvifGeneration()
492
+ }
493
+
494
+ /** @type {Set<string>} */
495
+ const usedAvifAbs = new Set()
496
+ await checkVueAvifImports(ignorePaths, usedAvifAbs, pass, fail)
497
+ await cleanupOrphanAvifs(usedAvifAbs, ignorePaths, pass)
328
498
 
329
499
  return reporter.getExitCode()
330
500
  }
@@ -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)
@@ -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
+ }