@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.
@@ -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
- const conc = getObjKey(root, 'concurrency')
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 (/\bn-cursor\s+lint-ga\b/.test(lg)) {
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)
@@ -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) {
@@ -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
 
@@ -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(cfg && typeof cfg === 'object' && /** @type {Record<string, unknown>} */ (cfg)['disable-avif'] === true)
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-пакета на raster-імпорти, що ще не використовують `.avif`.
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 vueFiles = []
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
- vueFiles.push(absPath)
282
+ targetFiles.push(absPath)
231
283
  },
232
284
  ignorePaths
233
285
  )
234
- if (vueFiles.length === 0) return
286
+ if (targetFiles.length === 0) return
235
287
 
236
288
  let violations = 0
237
- for (const absPath of vueFiles) {
289
+ let replacements = 0
290
+ for (const absPath of targetFiles) {
238
291
  const rel = relative(process.cwd(), absPath).split('\\').join('/')
239
- const content = await readFile(absPath, 'utf8')
240
- for (const match of content.matchAll(VUE_RASTER_IMPORT_RE)) {
241
- violations++
242
- const importPath = match[1]
243
- fail(
244
- `[${label}] ${rel}: import з '${importPath}' має посилатись на AVIF-двійник '${importPath}.avif' ` +
245
- `(lint-image --avif створює його поряд). Вимкнути локально: "@nitra/minify-image": { "disable-avif": true } у package.json пакета`
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
- for (const match of content.matchAll(VUE_RASTER_STATIC_SRC_RE)) {
249
- violations++
250
- const srcPath = match[1]
251
- 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 =>
252
324
  `[${label}] ${rel}: пряме \`src="${srcPath}"\` у шаблоні має використовувати AVIF-двійник \`src="${srcPath}.avif"\` ` +
253
- `(або винеси у import + \`:src="..."\`). Вимкнути локально: "@nitra/minify-image": { "disable-avif": true } у package.json пакета`
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
- pass(`[${label}] усі raster-посилання у .vue вже на .avif (або відсутні)`)
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(`[${root === '.' ? 'корінь' : root}] avif-import enforcement вимкнено через "@nitra/minify-image.disable-avif"`)
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 --avif`, агрегований `lint`,
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` видалений, AVIF-імпорти у `.vue` файлах. CI-workflow
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
- 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)
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, sep } from 'node:path'
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('.oxlintrc.json: поле "ignorePatterns" має бути масивом (канон задає мінімум, додаткові патерни дозволені)')
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, sep } from 'node:path'
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)