@nitra/cursor 1.8.180 → 1.8.185
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 +45 -5
- package/mdc/ga.mdc +23 -1
- package/mdc/image.mdc +17 -20
- package/mdc/js-run.mdc +21 -1
- package/package.json +4 -4
- package/scripts/check-abie.mjs +1 -0
- package/scripts/check-changelog.mjs +49 -50
- package/scripts/check-ga.mjs +69 -8
- package/scripts/check-hasura.mjs +1 -0
- package/scripts/check-image.mjs +221 -47
- package/scripts/check-js-bun-db.mjs +3 -22
- package/scripts/check-js-lint.mjs +3 -1
- package/scripts/check-js-mssql.mjs +5 -23
- package/scripts/check-js-run.mjs +37 -3
- package/scripts/check-k8s.mjs +32 -31
- package/scripts/check-vue.mjs +17 -10
- package/scripts/claude-stop-hook.mjs +1 -0
- package/scripts/lint-ga.mjs +0 -1
- package/scripts/run-docker.mjs +1 -0
- package/scripts/run-k8s.mjs +1 -0
- package/scripts/utils/bun-sql-scan.mjs +1 -2
- package/scripts/utils/depcheck-workflow.mjs +188 -0
- package/scripts/utils/find-package-json-paths.mjs +30 -0
- package/scripts/utils/load-cursor-config.mjs +3 -1
- package/scripts/utils/oxlint-canonical.json +3 -16
- package/scripts/utils/walkDir.mjs +4 -2
package/scripts/check-ga.mjs
CHANGED
|
@@ -43,6 +43,8 @@ const MEGALINTER_USE_PATTERNS = [/oxsecurity\/megalinter-action/i, /megalinter\/
|
|
|
43
43
|
/** Типові конфіги MegaLinter у корені репо */
|
|
44
44
|
const MEGALINTER_CONFIG_NAMES = ['.mega-linter.yml', '.megalinter.yaml', '.mega-linter.yaml']
|
|
45
45
|
|
|
46
|
+
const N_CURSOR_LINT_GA_RE = /\bn-cursor\s+lint-ga\b/
|
|
47
|
+
|
|
46
48
|
/** Локальні composite setup-bun-deps (ga.mdc). */
|
|
47
49
|
const SETUP_BUN_PATTERNS = ['./.github/actions/setup-bun-deps', './npm/github-actions/setup-bun-deps']
|
|
48
50
|
|
|
@@ -56,6 +58,9 @@ const FORBIDDEN_BUN_PATTERNS = [
|
|
|
56
58
|
/** Обовʼязкові workflow-файли (ga.mdc). */
|
|
57
59
|
const REQUIRED_WORKFLOWS = ['clean-ga-workflows.yml', 'clean-merged-branch.yml', 'lint-ga.yml', 'git-ai.yml']
|
|
58
60
|
|
|
61
|
+
/** Канонічне значення `concurrency.group` (ga.mdc). Збирається з фрагментів, щоб не плодити expression-токени в коді. */
|
|
62
|
+
const EXPECTED_CONCURRENCY_GROUP = ['$', '{{ github.ref }}-$', '{{ github.workflow }}'].join('')
|
|
63
|
+
|
|
59
64
|
/**
|
|
60
65
|
* Повертає true, якщо glob у GitHub Actions `on.*.paths` матчитсья хоча б на один tracked файл у репозиторії.
|
|
61
66
|
*
|
|
@@ -229,6 +234,8 @@ function validateCleanGaWorkflows(root, passFn, failFn) {
|
|
|
229
234
|
passFn('clean-ga-workflows.yml: workflow_dispatch OK')
|
|
230
235
|
}
|
|
231
236
|
|
|
237
|
+
validateConcurrencyOnRoot('clean-ga-workflows.yml', root, passFn, failFn)
|
|
238
|
+
|
|
232
239
|
const jobs = getObjKey(root, 'jobs')
|
|
233
240
|
const job = getObjKey(jobs, 'cleanup_old_workflows')
|
|
234
241
|
if (!job) {
|
|
@@ -342,6 +349,8 @@ function validateCleanMergedBranch(root, passFn, failFn) {
|
|
|
342
349
|
failFn('clean-merged-branch.yml: має бути workflow_dispatch: {} (ga.mdc)')
|
|
343
350
|
}
|
|
344
351
|
|
|
352
|
+
validateConcurrencyOnRoot('clean-merged-branch.yml', root, passFn, failFn)
|
|
353
|
+
|
|
345
354
|
const jobs = getObjKey(root, 'jobs')
|
|
346
355
|
const job = getObjKey(jobs, 'cleanup_old_branches')
|
|
347
356
|
if (!job) {
|
|
@@ -419,10 +428,7 @@ function validateLintGaWorkflowStructure(root, passFn, failFn) {
|
|
|
419
428
|
|
|
420
429
|
validateLintGaOnTriggers(root.on, failFn)
|
|
421
430
|
|
|
422
|
-
|
|
423
|
-
if (getObjKey(conc, 'cancel-in-progress') !== true) {
|
|
424
|
-
failFn('lint-ga.yml: concurrency.cancel-in-progress має бути true (ga.mdc)')
|
|
425
|
-
}
|
|
431
|
+
validateConcurrencyOnRoot('lint-ga.yml', root, passFn, failFn)
|
|
426
432
|
|
|
427
433
|
const jobs = getObjKey(root, 'jobs')
|
|
428
434
|
const job = getObjKey(jobs, 'lint-ga')
|
|
@@ -488,6 +494,8 @@ function validateGitAiWorkflowStructure(root, passFn, failFn) {
|
|
|
488
494
|
failFn('git-ai.yml: on.pull_request.types має містити closed (ga.mdc)')
|
|
489
495
|
}
|
|
490
496
|
|
|
497
|
+
validateConcurrencyOnRoot('git-ai.yml', root, passFn, failFn)
|
|
498
|
+
|
|
491
499
|
const jobs = getObjKey(root, 'jobs')
|
|
492
500
|
const job = getObjKey(jobs, 'git-ai')
|
|
493
501
|
if (!job) {
|
|
@@ -516,6 +524,60 @@ function validateGitAiWorkflowStructure(root, passFn, failFn) {
|
|
|
516
524
|
}
|
|
517
525
|
}
|
|
518
526
|
|
|
527
|
+
/**
|
|
528
|
+
* Перевіряє блок `concurrency` на вже розпарсеному корені workflow (ga.mdc).
|
|
529
|
+
*
|
|
530
|
+
* Використовується в канонічних структурних валідаторах (clean-ga-workflows, clean-merged-branch,
|
|
531
|
+
* lint-ga, git-ai), де root уже отримано через `parseWorkflowYaml`. Логіка ідентична
|
|
532
|
+
* `verifyConcurrencyBlock`, але без повторного парсингу.
|
|
533
|
+
* @param {string} relPath шлях для повідомлень
|
|
534
|
+
* @param {Record<string, unknown>} root parsed YAML workflow
|
|
535
|
+
* @param {(msg: string) => void} passFn pass
|
|
536
|
+
* @param {(msg: string) => void} failFn fail
|
|
537
|
+
* @returns {void}
|
|
538
|
+
*/
|
|
539
|
+
function validateConcurrencyOnRoot(relPath, root, passFn, failFn) {
|
|
540
|
+
const conc = getObjKey(root, 'concurrency')
|
|
541
|
+
if (!conc || typeof conc !== 'object') {
|
|
542
|
+
failFn(
|
|
543
|
+
`${relPath}: відсутня секція concurrency — додай concurrency.group: ${EXPECTED_CONCURRENCY_GROUP} і cancel-in-progress: true (ga.mdc)`
|
|
544
|
+
)
|
|
545
|
+
return
|
|
546
|
+
}
|
|
547
|
+
const group = getObjKey(conc, 'group')
|
|
548
|
+
const cancel = getObjKey(conc, 'cancel-in-progress')
|
|
549
|
+
if (group !== EXPECTED_CONCURRENCY_GROUP) {
|
|
550
|
+
failFn(`${relPath}: concurrency.group має бути ${EXPECTED_CONCURRENCY_GROUP} (ga.mdc)`)
|
|
551
|
+
return
|
|
552
|
+
}
|
|
553
|
+
if (cancel !== true) {
|
|
554
|
+
failFn(`${relPath}: concurrency.cancel-in-progress має бути true (ga.mdc)`)
|
|
555
|
+
return
|
|
556
|
+
}
|
|
557
|
+
passFn(`${relPath}: concurrency блок OK`)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* Перевіряє, що workflow містить блок `concurrency` з канонічними `group` і `cancel-in-progress: true` (ga.mdc).
|
|
562
|
+
*
|
|
563
|
+
* Без винятків — застосовується до всіх workflow у `.github/workflows/*.yml`, включно з scheduled cleanup,
|
|
564
|
+
* `pull_request: types: [closed]` та publish-воркфлоу. Делегує логіку `validateConcurrencyOnRoot`,
|
|
565
|
+
* додаючи лише крок парсингу YAML; якщо парсинг провалився — мовчки виходить (синтаксичні проблеми
|
|
566
|
+
* ловлять інші перевірки).
|
|
567
|
+
* @param {string} relPath шлях для повідомлень
|
|
568
|
+
* @param {string} content вміст YAML
|
|
569
|
+
* @param {(msg: string) => void} failFn реєструє порушення (exit 1)
|
|
570
|
+
* @param {(msg: string) => void} passFn реєструє успішну перевірку
|
|
571
|
+
* @returns {void}
|
|
572
|
+
*/
|
|
573
|
+
function verifyConcurrencyBlock(relPath, content, failFn, passFn) {
|
|
574
|
+
const root = parseWorkflowYaml(content)
|
|
575
|
+
if (!root) {
|
|
576
|
+
return
|
|
577
|
+
}
|
|
578
|
+
validateConcurrencyOnRoot(relPath, root, passFn, failFn)
|
|
579
|
+
}
|
|
580
|
+
|
|
519
581
|
/**
|
|
520
582
|
* Якщо workflow викликає локальний setup-bun-deps, раніше у файлі має бути `actions/checkout@v…` (ga.mdc).
|
|
521
583
|
* Fallback: сирий текст, якщо YAML не вдається розібрати.
|
|
@@ -743,12 +805,10 @@ async function checkLintGaScript(passFn, failFn) {
|
|
|
743
805
|
// на shellcheck + послідовно `bunx github-actionlint` і `uvx zizmor --offline --collect=workflows .`.
|
|
744
806
|
// Виклик через bin-ім’я `n-cursor`, а не `npx --no @nitra/cursor`, бо `bun run` транслює `npx` у `bun x`,
|
|
745
807
|
// а `bun x @nitra/cursor` для скоупованого пакету з одним bin-ім’ям повертає 0 без виконання.
|
|
746
|
-
if (
|
|
808
|
+
if (N_CURSOR_LINT_GA_RE.test(lg)) {
|
|
747
809
|
passFn('lint-ga делегує CLI n-cursor lint-ga (preflight shellcheck + actionlint + zizmor)')
|
|
748
810
|
} else {
|
|
749
|
-
failFn(
|
|
750
|
-
'lint-ga має бути "n-cursor lint-ga" — CLI робить preflight shellcheck перед actionlint/zizmor (ga.mdc)'
|
|
751
|
-
)
|
|
811
|
+
failFn('lint-ga має бути "n-cursor lint-ga" — CLI робить preflight shellcheck перед actionlint/zizmor (ga.mdc)')
|
|
752
812
|
}
|
|
753
813
|
}
|
|
754
814
|
|
|
@@ -994,6 +1054,7 @@ export async function check() {
|
|
|
994
1054
|
verifyCheckoutBeforeLocalSetupBunDeps(`${wfDir}/${f}`, content, fail, pass)
|
|
995
1055
|
verifyNoDirectBunOrCache(`${wfDir}/${f}`, content, fail, pass)
|
|
996
1056
|
verifyNoRunShellLineContinuationBackslash(`${wfDir}/${f}`, content, fail, pass)
|
|
1057
|
+
verifyConcurrencyBlock(`${wfDir}/${f}`, content, fail, pass)
|
|
997
1058
|
const parsed = parseWorkflowYaml(content)
|
|
998
1059
|
if (parsed) {
|
|
999
1060
|
verifyWorkflowEventPathsGlobsExist(`${wfDir}/${f}`, parsed, pass, fail)
|
package/scripts/check-hasura.mjs
CHANGED
|
@@ -102,6 +102,7 @@ export function isEnvFile(relPath) {
|
|
|
102
102
|
/**
|
|
103
103
|
* Збирає всі `*.env` файли в дереві, окрім службових каталогів.
|
|
104
104
|
* @param {string} root абсолютний шлях кореня
|
|
105
|
+
* @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
|
|
105
106
|
* @returns {Promise<string[]>} відсортовані posix-шляхи відносно кореня
|
|
106
107
|
*/
|
|
107
108
|
async function collectEnvFiles(root, ignorePaths) {
|
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
|
|
|
@@ -187,9 +213,7 @@ async function checkLegacyCacheRemoved(pass, fail) {
|
|
|
187
213
|
}
|
|
188
214
|
const lines = await readGitignoreLines()
|
|
189
215
|
if (lines && lines.includes(LEGACY_CACHE_FILENAME)) {
|
|
190
|
-
fail(
|
|
191
|
-
`.gitignore: прибери застарілий рядок \`${LEGACY_CACHE_FILENAME}\` — split-cache 3.2.0 його не використовує`
|
|
192
|
-
)
|
|
216
|
+
fail(`.gitignore: прибери застарілий рядок \`${LEGACY_CACHE_FILENAME}\` — split-cache 3.2.0 його не використовує`)
|
|
193
217
|
return
|
|
194
218
|
}
|
|
195
219
|
pass(`${LEGACY_CACHE_FILENAME} відсутній (міграція на split-cache завершена)`)
|
|
@@ -203,59 +227,117 @@ async function checkLegacyCacheRemoved(pass, fail) {
|
|
|
203
227
|
*/
|
|
204
228
|
function packageHasAvifDisabled(pkg) {
|
|
205
229
|
const cfg = pkg[PKG_CONFIG_FIELD]
|
|
206
|
-
return Boolean(
|
|
230
|
+
return Boolean(
|
|
231
|
+
cfg && typeof cfg === 'object' && /** @type {Record<string, unknown>} */ (cfg)['disable-avif'] === true
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
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
|
|
207
251
|
}
|
|
208
252
|
|
|
209
253
|
/**
|
|
210
|
-
* Сканує `.vue` файли одного workspace
|
|
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-коренів — їх піддерева пропускаємо
|
|
265
|
+
* @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
|
|
266
|
+
* @param {Set<string>} usedAvifAbs мутабельна множина абсолютних шляхів `.avif`, що мають
|
|
267
|
+
* хоч одне посилання у `.vue`/`.html` (доповнюється у цій функції)
|
|
216
268
|
* @param {(msg: string) => void} pass callback при успішній перевірці
|
|
217
269
|
* @param {(msg: string) => void} fail callback при помилці
|
|
218
|
-
* @returns {Promise<void>}
|
|
270
|
+
* @returns {Promise<void>} резолвиться по завершенню перевірки одного пакета
|
|
219
271
|
*/
|
|
220
|
-
async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePaths, pass, fail) {
|
|
272
|
+
async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePaths, usedAvifAbs, pass, fail) {
|
|
221
273
|
const absRoot = join(process.cwd(), packageRoot)
|
|
222
274
|
const label = packageRoot === '.' ? 'корінь' : packageRoot
|
|
223
275
|
/** @type {string[]} */
|
|
224
|
-
const
|
|
276
|
+
const targetFiles = []
|
|
225
277
|
await walkDir(
|
|
226
278
|
absRoot,
|
|
227
279
|
absPath => {
|
|
228
|
-
if (!absPath.endsWith('.vue')) return
|
|
280
|
+
if (!absPath.endsWith('.vue') && !absPath.endsWith('.html')) return
|
|
229
281
|
if (otherRootsAbs.some(other => absPath.startsWith(`${other}/`))) return
|
|
230
|
-
|
|
282
|
+
targetFiles.push(absPath)
|
|
231
283
|
},
|
|
232
284
|
ignorePaths
|
|
233
285
|
)
|
|
234
|
-
if (
|
|
286
|
+
if (targetFiles.length === 0) return
|
|
235
287
|
|
|
236
288
|
let violations = 0
|
|
237
|
-
|
|
289
|
+
let replacements = 0
|
|
290
|
+
for (const absPath of targetFiles) {
|
|
238
291
|
const rel = relative(process.cwd(), absPath).split('\\').join('/')
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
+
})
|
|
247
313
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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 =>
|
|
252
324
|
`[${label}] ${rel}: пряме \`src="${srcPath}"\` у шаблоні має використовувати AVIF-двійник \`src="${srcPath}.avif"\` ` +
|
|
253
|
-
|
|
254
|
-
|
|
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')
|
|
255
336
|
}
|
|
256
337
|
}
|
|
257
338
|
if (violations === 0) {
|
|
258
|
-
|
|
339
|
+
const summary = replacements > 0 ? `усі raster-посилання у .vue/.html переписано на .avif (замін: ${replacements})` : 'усі raster-посилання у .vue/.html вже на .avif (або відсутні)'
|
|
340
|
+
pass(`[${label}] ${summary}`)
|
|
259
341
|
}
|
|
260
342
|
}
|
|
261
343
|
|
|
@@ -263,11 +345,14 @@ async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePa
|
|
|
263
345
|
* Сканує всі workspace-пакети: для кожного перевіряє opt-out і за потреби викликає
|
|
264
346
|
* перевірку Vue-imports. Перевірка пропускається, якщо в репозиторії немає workspaces
|
|
265
347
|
* або немає `.vue`-файлів — тоді `image` правило не для цього проєкту.
|
|
348
|
+
* @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
|
|
349
|
+
* @param {Set<string>} usedAvifAbs мутабельна множина абсолютних шляхів `.avif`-двійників,
|
|
350
|
+
* на які лишилось хоча б одне посилання у `.vue`/`.html` (заповнюється у викликаних функціях)
|
|
266
351
|
* @param {(msg: string) => void} pass callback при успішній перевірці
|
|
267
352
|
* @param {(msg: string) => void} fail callback при помилці
|
|
268
|
-
* @returns {Promise<void>}
|
|
353
|
+
* @returns {Promise<void>} резолвиться по завершенню перевірки всіх workspace-пакетів
|
|
269
354
|
*/
|
|
270
|
-
async function checkVueAvifImports(ignorePaths, pass, fail) {
|
|
355
|
+
async function checkVueAvifImports(ignorePaths, usedAvifAbs, pass, fail) {
|
|
271
356
|
const roots = await getMonorepoPackageRootDirs()
|
|
272
357
|
const absRootsByRel = new Map(roots.map(r => [r, join(process.cwd(), r)]))
|
|
273
358
|
for (const root of roots) {
|
|
@@ -275,11 +360,91 @@ async function checkVueAvifImports(ignorePaths, pass, fail) {
|
|
|
275
360
|
if (!existsSync(pkgPath)) continue
|
|
276
361
|
const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
|
|
277
362
|
if (packageHasAvifDisabled(pkg)) {
|
|
278
|
-
pass(
|
|
363
|
+
pass(
|
|
364
|
+
`[${root === '.' ? 'корінь' : root}] avif-import enforcement вимкнено через "@nitra/minify-image.disable-avif"`
|
|
365
|
+
)
|
|
279
366
|
continue
|
|
280
367
|
}
|
|
281
368
|
const otherRootsAbs = roots.filter(r => r !== root && r !== '.').map(r => absRootsByRel.get(r) ?? '')
|
|
282
|
-
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}`)
|
|
283
448
|
}
|
|
284
449
|
}
|
|
285
450
|
|
|
@@ -304,9 +469,10 @@ async function checkPackageJsonImage(pass, fail) {
|
|
|
304
469
|
|
|
305
470
|
/**
|
|
306
471
|
* Перевіряє відповідність проєкту правилам `image.mdc` (split-cache 3.2.0):
|
|
307
|
-
* `lint-image` через `npx @nitra/minify-image --src=. --write
|
|
472
|
+
* `lint-image` через `npx @nitra/minify-image --src=. --write` (без `--avif`!), агрегований `lint`,
|
|
308
473
|
* `.n-minify-image.tsv` НЕ в `.gitignore` (committed source of truth), застарілий
|
|
309
|
-
* `.minify-image-cache.tsv`
|
|
474
|
+
* `.minify-image-cache.tsv` видалений. Окремо виконуються дії: запуск AVIF-генерації,
|
|
475
|
+
* авто-заміна raster-посилань у `.vue`/`.html`, видалення AVIF-сиріт. CI-workflow
|
|
310
476
|
* для image не вимагається — лінт зображень виконується лише локально.
|
|
311
477
|
* @returns {Promise<number>} 0 — все OK, 1 — є проблеми
|
|
312
478
|
*/
|
|
@@ -320,7 +486,15 @@ export async function check() {
|
|
|
320
486
|
await checkLegacyCacheRemoved(pass, fail)
|
|
321
487
|
}
|
|
322
488
|
const ignorePaths = await loadCursorIgnorePaths(process.cwd())
|
|
323
|
-
|
|
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)
|
|
324
498
|
|
|
325
499
|
return reporter.getExitCode()
|
|
326
500
|
}
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*/
|
|
24
24
|
import { existsSync } from 'node:fs'
|
|
25
25
|
import { readFile } from 'node:fs/promises'
|
|
26
|
-
import { join, relative
|
|
26
|
+
import { join, relative } from 'node:path'
|
|
27
27
|
|
|
28
28
|
import { createCheckReporter } from './utils/check-reporter.mjs'
|
|
29
29
|
import {
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
isBunSqlScanSourceFile,
|
|
36
36
|
textHasBunSqlImport
|
|
37
37
|
} from './utils/bun-sql-scan.mjs'
|
|
38
|
+
import { findAllPackageJsonPaths } from './utils/find-package-json-paths.mjs'
|
|
38
39
|
import { loadCursorIgnorePaths } from './utils/load-cursor-config.mjs'
|
|
39
40
|
import { walkDir } from './utils/walkDir.mjs'
|
|
40
41
|
|
|
@@ -50,30 +51,10 @@ function asObject(v) {
|
|
|
50
51
|
return /** @type {Record<string, unknown>} */ (v)
|
|
51
52
|
}
|
|
52
53
|
|
|
53
|
-
/**
|
|
54
|
-
* Знаходить всі `package.json` у репозиторії (крім пропущених директорій у walkDir).
|
|
55
|
-
* @param {string} repoRoot абсолютний шлях до кореня репозиторію
|
|
56
|
-
* @returns {Promise<string[]>} абсолютні шляхи, відсортовані за відносним шляхом
|
|
57
|
-
*/
|
|
58
|
-
async function findAllPackageJsonPaths(repoRoot, ignorePaths) {
|
|
59
|
-
/** @type {string[]} */
|
|
60
|
-
const paths = []
|
|
61
|
-
await walkDir(
|
|
62
|
-
repoRoot,
|
|
63
|
-
absPath => {
|
|
64
|
-
if (absPath.endsWith(`${sep}package.json`)) {
|
|
65
|
-
paths.push(absPath)
|
|
66
|
-
}
|
|
67
|
-
},
|
|
68
|
-
ignorePaths
|
|
69
|
-
)
|
|
70
|
-
paths.sort((a, b) => relative(repoRoot, a).localeCompare(relative(repoRoot, b)))
|
|
71
|
-
return paths
|
|
72
|
-
}
|
|
73
|
-
|
|
74
54
|
/**
|
|
75
55
|
* Збирає абсолютні шляхи JS/TS джерел у репозиторії для скану Bun SQL патернів.
|
|
76
56
|
* @param {string} repoRoot абсолютний шлях до кореня репозиторію
|
|
57
|
+
* @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
|
|
77
58
|
* @returns {Promise<string[]>} абсолютні шляхи, відсортовані за відносним шляхом
|
|
78
59
|
*/
|
|
79
60
|
async function findAllSourcePathsForBunSqlScan(repoRoot, ignorePaths) {
|
|
@@ -145,7 +145,9 @@ function compareOxlintIgnorePatterns(expected, actual, failures) {
|
|
|
145
145
|
return
|
|
146
146
|
}
|
|
147
147
|
if (!Array.isArray(actual)) {
|
|
148
|
-
failures.push(
|
|
148
|
+
failures.push(
|
|
149
|
+
'.oxlintrc.json: поле "ignorePatterns" має бути масивом (канон задає мінімум, додаткові патерни дозволені)'
|
|
150
|
+
)
|
|
149
151
|
return
|
|
150
152
|
}
|
|
151
153
|
const set = new Set(actual)
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { existsSync } from 'node:fs'
|
|
12
12
|
import { readFile } from 'node:fs/promises'
|
|
13
|
-
import { join, relative
|
|
13
|
+
import { join, relative } from 'node:path'
|
|
14
14
|
|
|
15
15
|
import { createCheckReporter } from './utils/check-reporter.mjs'
|
|
16
|
+
import { findAllPackageJsonPaths } from './utils/find-package-json-paths.mjs'
|
|
16
17
|
import {
|
|
17
18
|
findMssqlPerRequestConnectionInText,
|
|
18
19
|
findSharedMssqlRequestInText,
|
|
@@ -31,30 +32,10 @@ const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)/u
|
|
|
31
32
|
/** Мінімальна дозволена версія mssql (js-mssql.mdc). */
|
|
32
33
|
const MIN_MSSQL_VERSION = { major: 12, minor: 5, patch: 0 }
|
|
33
34
|
|
|
34
|
-
/**
|
|
35
|
-
* Знаходить всі `package.json` у репозиторії (крім пропущених директорій у walkDir).
|
|
36
|
-
* @param {string} repoRoot абсолютний шлях до кореня репозиторію
|
|
37
|
-
* @returns {Promise<string[]>} абсолютні шляхи, відсортовані за відносним шляхом
|
|
38
|
-
*/
|
|
39
|
-
async function findAllPackageJsonPaths(repoRoot, ignorePaths) {
|
|
40
|
-
/** @type {string[]} */
|
|
41
|
-
const paths = []
|
|
42
|
-
await walkDir(
|
|
43
|
-
repoRoot,
|
|
44
|
-
absPath => {
|
|
45
|
-
if (absPath.endsWith(`${sep}package.json`)) {
|
|
46
|
-
paths.push(absPath)
|
|
47
|
-
}
|
|
48
|
-
},
|
|
49
|
-
ignorePaths
|
|
50
|
-
)
|
|
51
|
-
paths.sort((a, b) => relative(repoRoot, a).localeCompare(relative(repoRoot, b)))
|
|
52
|
-
return paths
|
|
53
|
-
}
|
|
54
|
-
|
|
55
35
|
/**
|
|
56
36
|
* Збирає абсолютні шляхи JS/TS джерел у репозиторії для скану mssql.
|
|
57
37
|
* @param {string} repoRoot абсолютний шлях до кореня репозиторію
|
|
38
|
+
* @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
|
|
58
39
|
* @returns {Promise<string[]>} абсолютні шляхи, відсортовані за відносним шляхом
|
|
59
40
|
*/
|
|
60
41
|
async function findAllSourcePathsForMssqlScan(repoRoot, ignorePaths) {
|
|
@@ -258,9 +239,10 @@ function reportZeroMssqlSourceViolations(counters, pass) {
|
|
|
258
239
|
/**
|
|
259
240
|
* Аудит усіх JS/TS-джерел репо щодо безпечного використання mssql.
|
|
260
241
|
* @param {string} repoRoot корінь репозиторію
|
|
242
|
+
* @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
|
|
261
243
|
* @param {(msg: string) => void} pass pass callback
|
|
262
244
|
* @param {(msg: string) => void} fail fail callback
|
|
263
|
-
* @returns {Promise<void>}
|
|
245
|
+
* @returns {Promise<void>} резолвиться по завершенню аудиту всіх знайдених джерел
|
|
264
246
|
*/
|
|
265
247
|
async function auditMssqlSources(repoRoot, ignorePaths, pass, fail) {
|
|
266
248
|
const sourcePaths = await findAllSourcePathsForMssqlScan(repoRoot, ignorePaths)
|