@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 +36 -0
- package/mdc/image.mdc +17 -20
- package/mdc/js-run.mdc +13 -1
- package/mdc/vue.mdc +41 -2
- package/package.json +1 -1
- package/scripts/check-image.mjs +210 -40
- 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,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
|
+
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` — авто-оптимізація з
|
|
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
|
|
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`, перед фіксацією одразу бачить, чи зросли
|
|
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** — це готові артефакти для віддачі браузеру (без них ефект від
|
|
50
|
+
AVIF-двійники (`<name>.<ext>.avif`) **зберігаємо в git** — це готові артефакти для віддачі браузеру (без них ефект від AVIF втрачається на чистому checkout-і).
|
|
51
51
|
|
|
52
52
|
## AVIF-імпорти у `.vue`
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
AVIF-двійники (`<name>.<ext>.avif`) генерує **виключно** `npx @nitra/cursor check image` — у `lint-image` прапорець `--avif` заборонений (інакше `bun run lint` плодить непотрібні `.avif` для зображень, що ніде не використовуються). Перевірка робить три кроки в порядку:
|
|
55
55
|
|
|
56
|
-
|
|
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
|
|
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
|
|
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.
|
|
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-image.mjs
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Очікування:
|
|
6
6
|
* - у кореневому `package.json` є скрипт `lint-image`, який викликає `npx @nitra/minify-image`
|
|
7
|
-
* з обовʼязковими `--src
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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` (авто-оптимізація на місці)
|
|
66
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
282
|
+
targetFiles.push(absPath)
|
|
232
283
|
},
|
|
233
284
|
ignorePaths
|
|
234
285
|
)
|
|
235
|
-
if (
|
|
286
|
+
if (targetFiles.length === 0) return
|
|
236
287
|
|
|
237
288
|
let violations = 0
|
|
238
|
-
|
|
289
|
+
let replacements = 0
|
|
290
|
+
for (const absPath of targetFiles) {
|
|
239
291
|
const rel = relative(process.cwd(), absPath).split('\\').join('/')
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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`
|
|
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
|
-
|
|
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
|
}
|
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
|
+
}
|