@nitra/cursor 1.8.191 → 1.8.193

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@
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.193] - 2026-05-07
8
+
9
+ ### Fixed
10
+
11
+ - `check-image.mjs`: cleanup AVIF-сиріт більше не зачіпає `.avif` файли всередині пакетів з опт-аутом (`"@nitra/minify-image": { "disable-avif": true }`). Раніше: пакет з опт-аутом не сканувався на refs → його `.avif` потрапляли у список «сиріт» і видалялись, навіть якщо насправді використовувалися через alias / runtime-обчислений шлях. Тепер `checkVueAvifImports` повертає список абсолютних коренів opt-out пакетів, а `cleanupOrphanAvifs` пропускає `.avif` під ними.
12
+ - `check-image.mjs`: запис у `.vue`/`.html` тепер строго послідовний з cleanup (write-then-cleanup): перший виконує `checkVueAvifImports` (per-file `writeFile` після обробки), і тільки після цього `cleanupOrphanAvifs` читає вже оновлені `usedAvifAbs` і видаляє лише дійсних сиріт.
13
+ - `check-image.mjs`: введено агреговані лічильники `RewriteStats` (`rewrittenRefs` / `rewrittenFiles` / `failedRefs`) і єдиний фінальний рядок-підсумок `image: rewrote N references in M files; deleted K orphan AVIFs; failed to rewrite L references` — раніше підсумок дублювався per-package і не виокремлював orphan-cleanup vs failed-rewrites.
14
+
15
+ ### Added
16
+
17
+ - `tests/check-image.test.mjs`: 5 нових кейсів — статичний `<img src="a.png">` авто-переписується (за наявності `a.png` і `a.png.avif`); реактивне `:src="dyn"` залишається незмінним і orphan AVIF видаляється; змішані форми у одному файлі (статичний + import + реактивний + `data-src=`) — переписуються лише покривані; opt-out пакет — AVIF всередині не вважається сиротою; ідемпотентність повторного `check image` на чистому стані.
18
+
19
+ ## [1.8.192] - 2026-05-07
20
+
21
+ ### Added
22
+
23
+ - `run-shellcheck-text.mjs`: для `lint-text` — перевірка наявності `shellcheck`/`patch`, авто-виправлення через `shellcheck -f diff` + `patch -p1`, фінальний прогін по tracked `*.sh` (git) або `**/*.sh` без `node_modules`.
24
+ - `text` (mdc v1.25 → v1.26): **shellcheck** у ланцюжку `lint-text`, рекомендація **`timonwong.shellcheck`**, тригер workflow **`**/*.sh`**; тести `run-shellcheck-text.test.mjs`.
25
+
26
+ ### Changed
27
+
28
+ - `check-text.mjs`: `lint-text` має містити `run-shellcheck-text.mjs`; `extensions.json` — `timonwong.shellcheck`.
29
+
7
30
  ## [1.8.191] - 2026-05-07
8
31
 
9
32
  ### Added
package/mdc/text.mdc CHANGED
@@ -1,10 +1,10 @@
1
1
  ---
2
- description: Обробка та перевірка текстових файлів, oxfmt, cspell, markdownlint-cli2, v8r, CI
2
+ description: Обробка та перевірка текстових файлів, oxfmt, cspell, shellcheck (sh), markdownlint-cli2, v8r, CI
3
3
  alwaysApply: true
4
- version: '1.25'
4
+ version: '1.26'
5
5
  ---
6
6
 
7
- **oxfmt** (`.oxfmtrc.json`, редактор), **cspell**, **markdownlint-cli2**, **[v8r](https://chris48s.github.io/v8r/)** ([Schema Store](https://www.schemastore.org/)), розширення **DavidAnson.vscode-markdownlint**, workflow **`lint-text`**.
7
+ **oxfmt** (`.oxfmtrc.json`, редактор), **cspell**, **shellcheck** (tracked `*.sh` у `lint-text`), **markdownlint-cli2**, **[v8r](https://chris48s.github.io/v8r/)** ([Schema Store](https://www.schemastore.org/)), розширення **DavidAnson.vscode-markdownlint** / **timonwong.shellcheck**, workflow **`lint-text`**.
8
8
 
9
9
  ```json title=".vscode/extensions.json"
10
10
  {
@@ -13,6 +13,7 @@ version: '1.25'
13
13
  "github.vscode-github-actions",
14
14
  "oxc.oxc-vscode",
15
15
  "DavidAnson.vscode-markdownlint",
16
+ "timonwong.shellcheck",
16
17
  "redhat.vscode-yaml",
17
18
  "irongeek.vscode-env"
18
19
  ]
@@ -112,12 +113,14 @@ version: '1.25'
112
113
 
113
114
  **`package.json`:** скрипт **`lint-text`** і devDependencies **`@nitra/cspell-dict`** (**`^2.0.0`** або новіший у лінії 2.x) — з **2.0.0** у пакет транзитивно входять типові **`@cspell/dict-*`**, тому **не** додавай їх окремо в корінь. **`markdownlint-cli2`** викликай у `lint-text` лише через **`bunx markdownlint-cli2`**, не додавай пакет до devDependencies. **`v8r`** лише через **`bunx v8r`** (зазвичай **`bunx v8r`**), не в devDependencies. Окремий пакет **`markdownlint`** не потрібний.
114
115
 
116
+ **shellcheck:** інструмент лише в **`PATH`** (як у **ga.mdc** для `lint-ga`), **не** додавай до `dependencies` / `devDependencies`. Якщо `shellcheck` відсутній — встанови локально (**macOS:** `brew install shellcheck`; **Debian/Ubuntu:** `sudo apt-get install -y shellcheck`; **Arch:** `sudo pacman -S shellcheck`). У **`lint-text`** після **`cspell`** викликай **`bun ./npm/scripts/run-shellcheck-text.mjs`** (у споживачі після синку — `node_modules/@nitra/cursor/scripts/run-shellcheck-text.mjs`): під капотом циклічно **`shellcheck -f diff`** і **`patch -p1`** для авто-виправлень, потім повний прогін **`shellcheck`** по всіх tracked `*.sh` (у git) або по `**/*.sh` без `node_modules`. Потрібен також **`patch`** у `PATH` (на macOS зазвичай уже є).
117
+
115
118
  У v8r **немає** прапорця тихого режиму; рекомендовано скрипт **`run-v8r.mjs`** з репозиторію пакета `@nitra/cursor` (`npm/scripts/run-v8r.mjs`): один виклик у `lint-text` — під капотом послідовні **`bunx v8r`** для кожного типу (**json**, **json5**, **yml**, **yaml**, **toml**), бо один процес v8r з кількома глобами падає з **98**, якщо хоч один glob порожній, і тоді інші розширення не перевіряються. Вивід при кодах **0** і **98** не показується. Каталог схем **`schemas/v8r-catalog.json`** пакета `@nitra/cursor` скрипт підставляє в v8r сам. За бажання можна передати власні glob-и аргументами скрипта. Шлях до скрипта: `./npm/scripts/…`, `./scripts/…` після копіювання, або `node_modules/@nitra/cursor/scripts/…`.
116
119
 
117
120
  ```json title="package.json"
118
121
  {
119
122
  "scripts": {
120
- "lint-text": "npx cspell . && bunx markdownlint-cli2 --fix \"**/*.md\" \"**/*.mdc\" && bun ./npm/scripts/run-v8r.mjs"
123
+ "lint-text": "npx cspell . && bun ./npm/scripts/run-shellcheck-text.mjs && bunx markdownlint-cli2 --fix \"**/*.md\" \"**/*.mdc\" && bun ./npm/scripts/run-v8r.mjs"
121
124
  },
122
125
  "devDependencies": {
123
126
  "@nitra/cspell-dict": "^2.1.0"
@@ -185,6 +188,7 @@ on:
185
188
  - '**/*.go'
186
189
  - '**/*.py'
187
190
  - '**/*.php'
191
+ - '**/*.sh'
188
192
 
189
193
  pull_request:
190
194
  branches:
@@ -232,7 +236,7 @@ jobs:
232
236
  ```json title="package.json"
233
237
  {
234
238
  "scripts": {
235
- "lint-text": "npx cspell . && bunx markdownlint-cli2 --fix \"**/*.md\" \"**/*.mdc\" && bun ./npm/scripts/run-v8r.mjs"
239
+ "lint-text": "npx cspell . && bun ./npm/scripts/run-shellcheck-text.mjs && bunx markdownlint-cli2 --fix \"**/*.md\" \"**/*.mdc\" && bun ./npm/scripts/run-v8r.mjs"
236
240
  },
237
241
  "devDependencies": {
238
242
  "@nitra/cspell-dict": "^2.1.0"
@@ -249,7 +253,7 @@ jobs:
249
253
  ```json title="package.json"
250
254
  {
251
255
  "scripts": {
252
- "lint-text": "npx cspell . && bunx markdownlint-cli2 --fix \"**/*.md\" \"**/*.mdc\" && bun ./npm/scripts/run-v8r.mjs"
256
+ "lint-text": "npx cspell . && bun ./npm/scripts/run-shellcheck-text.mjs && bunx markdownlint-cli2 --fix \"**/*.md\" \"**/*.mdc\" && bun ./npm/scripts/run-v8r.mjs"
253
257
  },
254
258
  "devDependencies": {
255
259
  "@nitra/cspell-dict": "^2.1.0"
@@ -291,4 +295,4 @@ jobs:
291
295
 
292
296
  ## Перевірка
293
297
 
294
- `npx @nitra/cursor check text` (охоплює oxfmt, cspell, markdownlint, v8r, CI для `lint-text`)
298
+ `npx @nitra/cursor check text` (охоплює oxfmt, cspell, shellcheck у `lint-text`, markdownlint, v8r, CI для `lint-text`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.191",
3
+ "version": "1.8.193",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -250,13 +250,26 @@ function resolveImagePath(importPath, sourceAbsPath) {
250
250
  return null
251
251
  }
252
252
 
253
+ /**
254
+ * Аґреговані лічильники по проходу `check image`:
255
+ * - `rewrittenRefs` — скільки конкретних посилань (по одному на match) переписано на `.avif`;
256
+ * - `rewrittenFiles` — у скількох `.vue`/`.html` файлах хоч одне посилання змінилося;
257
+ * - `failedRefs` — скільки конкретних посилань не вдалося переписати (`.avif` не існував).
258
+ * @typedef {object} RewriteStats
259
+ * @property {number} rewrittenRefs
260
+ * @property {number} rewrittenFiles
261
+ * @property {number} failedRefs
262
+ */
263
+
253
264
  /**
254
265
  * Сканує `.vue` і `.html` файли одного workspace-пакета: де можемо, переписує raster-посилання
255
- * на `<path>.avif`, де не можемо — фейлимо. Повертає множину `.avif`-двійників, на які
256
- * лишилось живе посилання після проходу потрібно для подальшого прибирання сиріт.
266
+ * на `<path>.avif`, де не можемо — фейлимо. Доповнює `usedAvifAbs` шляхами AVIF-двійників, на
267
+ * які лишилось живе посилання, і `stats` лічильниками rewrite/fail для глобального підсумку.
257
268
  *
258
269
  * Заміна виконується ТІЛЬКИ якщо AVIF-двійник реально існує на диску. Якщо AVIF немає
259
270
  * (наприклад, оригіналу теж немає, тож `--avif` його не згенерував) — фейл, як раніше.
271
+ * Запис файла відбувається ОДРАЗУ після обробки одного файла (write-then-fail): провал на
272
+ * наступному файлі НЕ відкочує вже записані зміни попередніх.
260
273
  *
261
274
  * Файли, що належать іншим workspace-пакетам, ігноруються — кожен пакет перевіряється рівно
262
275
  * один раз (інакше при обході кореня `.` ми б повторно зайшли в `demo/` і подвоїли звіти).
@@ -265,11 +278,11 @@ function resolveImagePath(importPath, sourceAbsPath) {
265
278
  * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
266
279
  * @param {Set<string>} usedAvifAbs мутабельна множина абсолютних шляхів `.avif`, що мають
267
280
  * хоч одне посилання у `.vue`/`.html` (доповнюється у цій функції)
268
- * @param {(msg: string) => void} pass callback при успішній перевірці
281
+ * @param {RewriteStats} stats глобальні лічильники, що мутуються тут
269
282
  * @param {(msg: string) => void} fail callback при помилці
270
283
  * @returns {Promise<void>} резолвиться по завершенню перевірки одного пакета
271
284
  */
272
- async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePaths, usedAvifAbs, pass, fail) {
285
+ async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePaths, usedAvifAbs, stats, fail) {
273
286
  const absRoot = join(process.cwd(), packageRoot)
274
287
  const label = packageRoot === '.' ? 'корінь' : packageRoot
275
288
  /** @type {string[]} */
@@ -285,8 +298,6 @@ async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePa
285
298
  )
286
299
  if (targetFiles.length === 0) return
287
300
 
288
- let violations = 0
289
- let replacements = 0
290
301
  for (const absPath of targetFiles) {
291
302
  const rel = relative(process.cwd(), absPath).split('\\').join('/')
292
303
  const original = await readFile(absPath, 'utf8')
@@ -302,11 +313,11 @@ async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePa
302
313
  const replaced = full.replace(importPath, newImportPath)
303
314
  const imageAbs = resolveImagePath(importPath, absPath)
304
315
  if (imageAbs && existsSync(`${imageAbs}.avif`)) {
305
- replacements++
316
+ stats.rewrittenRefs++
306
317
  usedAvifAbs.add(`${imageAbs}.avif`)
307
318
  return replaced
308
319
  }
309
- violations++
320
+ stats.failedRefs++
310
321
  fail(renderFailure(importPath))
311
322
  return full
312
323
  })
@@ -333,28 +344,34 @@ async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePa
333
344
 
334
345
  if (updated !== original) {
335
346
  await writeFile(absPath, updated, 'utf8')
347
+ stats.rewrittenFiles++
336
348
  }
337
349
  }
338
- if (violations === 0) {
339
- const summary = replacements > 0 ? `усі raster-посилання у .vue/.html переписано на .avif (замін: ${replacements})` : 'усі raster-посилання у .vue/.html вже на .avif (або відсутні)'
340
- pass(`[${label}] ${summary}`)
341
- }
342
350
  }
343
351
 
344
352
  /**
345
353
  * Сканує всі workspace-пакети: для кожного перевіряє opt-out і за потреби викликає
346
354
  * перевірку Vue-imports. Перевірка пропускається, якщо в репозиторії немає workspaces
347
355
  * або немає `.vue`-файлів — тоді `image` правило не для цього проєкту.
356
+ *
357
+ * Повертає список абсолютних коренів пакетів, у яких ввімкнено opt-out (`disable-avif: true`).
358
+ * Це окремий результат, бо AVIF всередині такого пакета НЕ можна вважати «сиротою» лише
359
+ * на підставі відсутності посилань у його `.vue`/`.html` (ми взагалі не сканували його
360
+ * шаблони) — інакше cleanup помилково затирав би AVIF, що використовуються через alias /
361
+ * runtime-обчислений шлях / зовнішні посилання, які тут не видно.
348
362
  * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
349
363
  * @param {Set<string>} usedAvifAbs мутабельна множина абсолютних шляхів `.avif`-двійників,
350
364
  * на які лишилось хоча б одне посилання у `.vue`/`.html` (заповнюється у викликаних функціях)
365
+ * @param {RewriteStats} stats глобальні лічильники rewrite/fail (мутуються нижче)
351
366
  * @param {(msg: string) => void} pass callback при успішній перевірці
352
367
  * @param {(msg: string) => void} fail callback при помилці
353
- * @returns {Promise<void>} резолвиться по завершенню перевірки всіх workspace-пакетів
368
+ * @returns {Promise<string[]>} абсолютні шляхи коренів пакетів з активним opt-out
354
369
  */
355
- async function checkVueAvifImports(ignorePaths, usedAvifAbs, pass, fail) {
370
+ async function checkVueAvifImports(ignorePaths, usedAvifAbs, stats, pass, fail) {
356
371
  const roots = await getMonorepoPackageRootDirs()
357
372
  const absRootsByRel = new Map(roots.map(r => [r, join(process.cwd(), r)]))
373
+ /** @type {string[]} */
374
+ const optedOutAbs = []
358
375
  for (const root of roots) {
359
376
  const pkgPath = join(root, 'package.json')
360
377
  if (!existsSync(pkgPath)) continue
@@ -363,11 +380,13 @@ async function checkVueAvifImports(ignorePaths, usedAvifAbs, pass, fail) {
363
380
  pass(
364
381
  `[${root === '.' ? 'корінь' : root}] avif-import enforcement вимкнено через "@nitra/minify-image.disable-avif"`
365
382
  )
383
+ optedOutAbs.push(absRootsByRel.get(root) ?? join(process.cwd(), root))
366
384
  continue
367
385
  }
368
386
  const otherRootsAbs = roots.filter(r => r !== root && r !== '.').map(r => absRootsByRel.get(r) ?? '')
369
- await checkVueAvifImportsInPackage(root, otherRootsAbs, ignorePaths, usedAvifAbs, pass, fail)
387
+ await checkVueAvifImportsInPackage(root, otherRootsAbs, ignorePaths, usedAvifAbs, stats, fail)
370
388
  }
389
+ return optedOutAbs
371
390
  }
372
391
 
373
392
  /**
@@ -423,12 +442,18 @@ function runAvifGeneration() {
423
442
  * Видаляє AVIF-сироти — `<...>.avif` файли, на які не лишилось жодного посилання
424
443
  * у `.vue`/`.html` репозиторію. Реалізує умову правила: «AVIF лишається лише там,
425
444
  * де заміна реально вдалася».
445
+ *
446
+ * AVIF файли всередині opt-out пакетів (`disable-avif: true`) пропускаються — ми не
447
+ * сканували їх шаблони, тож не маємо права вважати їх AVIF сиротами. Це гарантує
448
+ * ідемпотентність повторного `check image` для пакетів, що навмисно вимкнули правило
449
+ * (наприклад, мобільний бандл, де AVIF підтримка не гарантована).
426
450
  * @param {Set<string>} usedAvifAbs абсолютні шляхи `.avif`, що мають живі посилання
451
+ * @param {string[]} optedOutAbs абсолютні шляхи коренів пакетів з опт-аутом —
452
+ * `.avif` під ними не вважаємо сиротами і не видаляємо
427
453
  * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
428
- * @param {(msg: string) => void} pass callback при успішній перевірці
429
- * @returns {Promise<void>} резолвиться після видалення всіх сиріт
454
+ * @returns {Promise<number>} кількість видалених сиріт
430
455
  */
431
- async function cleanupOrphanAvifs(usedAvifAbs, ignorePaths, pass) {
456
+ async function cleanupOrphanAvifs(usedAvifAbs, optedOutAbs, ignorePaths) {
432
457
  /** @type {string[]} */
433
458
  const orphans = []
434
459
  await walkDir(
@@ -436,6 +461,7 @@ async function cleanupOrphanAvifs(usedAvifAbs, ignorePaths, pass) {
436
461
  absPath => {
437
462
  if (!absPath.endsWith('.avif')) return
438
463
  if (usedAvifAbs.has(absPath)) return
464
+ if (optedOutAbs.some(root => absPath === root || absPath.startsWith(`${root}/`))) return
439
465
  orphans.push(absPath)
440
466
  },
441
467
  ignorePaths
@@ -443,9 +469,7 @@ async function cleanupOrphanAvifs(usedAvifAbs, ignorePaths, pass) {
443
469
  for (const absPath of orphans) {
444
470
  await unlink(absPath)
445
471
  }
446
- if (orphans.length > 0) {
447
- pass(`видалено AVIF-сиріт без посилань у .vue/.html: ${orphans.length}`)
448
- }
472
+ return orphans.length
449
473
  }
450
474
 
451
475
  /**
@@ -493,8 +517,16 @@ export async function check() {
493
517
 
494
518
  /** @type {Set<string>} */
495
519
  const usedAvifAbs = new Set()
496
- await checkVueAvifImports(ignorePaths, usedAvifAbs, pass, fail)
497
- await cleanupOrphanAvifs(usedAvifAbs, ignorePaths, pass)
520
+ /** @type {RewriteStats} */
521
+ const stats = { rewrittenRefs: 0, rewrittenFiles: 0, failedRefs: 0 }
522
+ const optedOutAbs = await checkVueAvifImports(ignorePaths, usedAvifAbs, stats, pass, fail)
523
+ const orphansDeleted = await cleanupOrphanAvifs(usedAvifAbs, optedOutAbs, ignorePaths)
524
+
525
+ pass(
526
+ `image: rewrote ${stats.rewrittenRefs} reference${stats.rewrittenRefs === 1 ? '' : 's'} in ${stats.rewrittenFiles} file${stats.rewrittenFiles === 1 ? '' : 's'}; ` +
527
+ `deleted ${orphansDeleted} orphan AVIF${orphansDeleted === 1 ? '' : 's'}; ` +
528
+ `failed to rewrite ${stats.failedRefs} reference${stats.failedRefs === 1 ? '' : 's'}`
529
+ )
498
530
 
499
531
  return reporter.getExitCode()
500
532
  }
@@ -10,7 +10,7 @@
10
10
  * дозволені лише **`@nitra/*`** (як у bun.mdc), зокрема **`@nitra/cspell-dict` ^2.0.0+**; без імпорту **`@cspell/dict-*`** у `.cspell.json`, заборона
11
11
  * `markdownlint-cli2` у dependencies/devDependencies, v8r (`run-v8r.mjs` або чотири `bunx v8r`),
12
12
  * `.v8rignore` (vscode JSON),
13
- * workflow `lint-text.yml`, розширення VSCode (markdownlint, oxc).
13
+ * workflow `lint-text.yml`, розширення VSCode (markdownlint, oxc, shellcheck), `run-shellcheck-text.mjs` у `lint-text`.
14
14
  *
15
15
  * Якщо є `.cursor/rules/n-text.mdc` і/або `npm/mdc/text.mdc` — перевіряє наявність абзацу про український
16
16
  * апостроф (U+0027 vs U+2019) і приклад з символом U+2019 у тексті.
@@ -121,7 +121,7 @@ async function checkVscodeTextExtensions(passFn, failFn) {
121
121
  try {
122
122
  const ext = JSON.parse(await readFile('.vscode/extensions.json', 'utf8'))
123
123
  const rec = ext.recommendations
124
- for (const id of ['DavidAnson.vscode-markdownlint', 'oxc.oxc-vscode']) {
124
+ for (const id of ['DavidAnson.vscode-markdownlint', 'oxc.oxc-vscode', 'timonwong.shellcheck']) {
125
125
  if (Array.isArray(rec) && rec.includes(id)) {
126
126
  passFn(`extensions.json містить ${id}`)
127
127
  } else {
@@ -329,15 +329,16 @@ function checkLintTextScript(lintText, passFn, failFn) {
329
329
  const ok =
330
330
  lt &&
331
331
  lt.includes('cspell') &&
332
+ lt.includes('run-shellcheck-text.mjs') &&
332
333
  lt.includes('bunx markdownlint-cli2') &&
333
334
  lt.includes('**/*.mdc') &&
334
335
  v8rTextOk &&
335
336
  (!globsRequired || globsOk)
336
337
  if (ok) {
337
- passFn('package.json: lint-text — v8r: run-v8r.mjs (один виклик або чотири) або чотири bunx v8r з || [ $? -eq 98 ]')
338
+ passFn('package.json: lint-text — shellcheck (run-shellcheck-text.mjs), v8r: run-v8r.mjs або чотири bunx v8r')
338
339
  } else {
339
340
  failFn(
340
- 'package.json: lint-text — v8r: bun ./…/run-v8r.mjs або чотири (bunx v8r "<glob>" || [ $? -eq 98 ]) для json/yml/yaml/toml (див. n-text.mdc)'
341
+ 'package.json: lint-text — додай bun ./…/run-shellcheck-text.mjs; v8r: bun ./…/run-v8r.mjs або чотири (bunx v8r "<glob>" || [ $? -eq 98 ]) (див. n-text.mdc)'
341
342
  )
342
343
  }
343
344
  }
@@ -407,7 +408,7 @@ async function checkCspellConfig(pass, fail) {
407
408
  }
408
409
 
409
410
  /**
410
- * Перевіряє відповідність проєкту правилам text.mdc (oxfmt, cspell, markdownlint через bunx, v8r)
411
+ * Перевіряє відповідність проєкту правилам text.mdc (oxfmt, cspell, shellcheck у lint-text, markdownlint через bunx, v8r)
411
412
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
412
413
  */
413
414
  export async function check() {
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Запуск shellcheck у ланцюжку lint-text: спочатку авто-застосування виправлень, потім фінальна перевірка.
3
+ *
4
+ * ShellCheck не має прапорця «--fix»; для виправлень, які інструмент уміє запропонувати, використовується
5
+ * формат виводу `diff` і застосування патчу через `patch -p1` у корені проєкту (шляхи у unified diff від ShellCheck
6
+ * узгоджуються з цим режимом).
7
+ *
8
+ * Якщо `shellcheck` відсутній у PATH, скрипт завершується з кодом 1 і друкує підказки встановлення
9
+ * (macOS: Homebrew; Debian/Ubuntu: apt; Arch: pacman). Аналогічно для `patch`, якщо його немає
10
+ * (рідко на macOS/Linux).
11
+ *
12
+ * Список файлів: у git-робочому дереві — `git ls-files` з pathspec `:(glob)` для всіх tracked `*.sh`;
13
+ * інакше — `globSync` з виключенням `node_modules`. Якщо скриптів не знайдено — вихід 0.
14
+ *
15
+ * Після циклу авто-виправлень виконується звичайний `shellcheck` по всіх зібраних файлах; будь-яке
16
+ * попередження чи помилка — ненульовий код виходу.
17
+ */
18
+ import { spawnSync } from 'node:child_process'
19
+ import { globSync } from 'node:fs'
20
+ import { resolve } from 'node:path'
21
+
22
+ import { isRunAsCli } from './cli-entry.mjs'
23
+ import { resolveCmd } from './utils/resolve-cmd.mjs'
24
+
25
+ /** Підрядок у stderr ShellCheck, коли є зауваження, але без авто-виправлення у форматі diff. */
26
+ const NON_AUTOFIXABLE_HINT = 'none were auto-fixable'
27
+
28
+ /** Максимум ітерацій `diff`+`patch` на один файл (захист від зациклення). */
29
+ const MAX_FIX_ROUNDS_PER_FILE = 32
30
+
31
+ /**
32
+ * Друкує підказки встановлення shellcheck у stderr.
33
+ * @returns {void}
34
+ */
35
+ function printShellcheckInstallHints() {
36
+ process.stderr.write(
37
+ [
38
+ '❌ shellcheck не знайдено в PATH.',
39
+ 'Встанови інструмент і повтори lint-text:',
40
+ ' macOS: brew install shellcheck',
41
+ ' Debian/Ubuntu: sudo apt-get install -y shellcheck',
42
+ ' Arch: sudo pacman -S shellcheck',
43
+ ''
44
+ ].join('\n')
45
+ )
46
+ }
47
+
48
+ /**
49
+ * Друкує підказку для відсутнього patch.
50
+ * @returns {void}
51
+ */
52
+ function printPatchInstallHints() {
53
+ process.stderr.write(
54
+ [
55
+ '❌ patch не знайдено в PATH (потрібен для застосування diff від shellcheck).',
56
+ ' macOS: patch зазвичай уже є; Debian/Ubuntu: sudo apt-get install -y patch',
57
+ ''
58
+ ].join('\n')
59
+ )
60
+ }
61
+
62
+ /**
63
+ * Повертає відносні шляхи до shell-скриптів для перевірки.
64
+ * @param {string} cwd корінь проєкту
65
+ * @returns {string[]} відсортований масив шляхів відносно cwd
66
+ */
67
+ export function listShellScriptPaths(cwd) {
68
+ const gitOk = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
69
+ cwd,
70
+ encoding: 'utf8',
71
+ env: process.env
72
+ })
73
+ if (gitOk.status === 0 && gitOk.stdout.trim() === 'true') {
74
+ const ls = spawnSync('git', ['ls-files', '-z', '--', ':(glob)**/*.sh'], {
75
+ cwd,
76
+ encoding: 'utf8',
77
+ env: process.env
78
+ })
79
+ if (ls.status !== 0) {
80
+ return []
81
+ }
82
+ const files = ls.stdout.split('\0').filter(Boolean)
83
+ return [...new Set(files)].sort()
84
+ }
85
+
86
+ const fromGlob = globSync('**/*.sh', {
87
+ cwd,
88
+ exclude: p =>
89
+ p.includes('node_modules') ||
90
+ p.startsWith(`node_modules/`) ||
91
+ p.split('/').includes('node_modules')
92
+ })
93
+ return [...new Set(fromGlob.map(p => p.replaceAll('\\', '/')))].sort()
94
+ }
95
+
96
+ /**
97
+ * Запускає shellcheck із авто-виправленнями і фінальною перевіркою.
98
+ * @param {string} [cwd] робочий каталог (за замовчуванням `process.cwd()`)
99
+ * @returns {number} 0 — OK; 1 — помилка середовища або залишкові зауваження shellcheck
100
+ */
101
+ export function runShellcheckText(cwd = process.cwd()) {
102
+ const root = resolve(cwd)
103
+ const shellcheck = resolveCmd('shellcheck')
104
+ if (!shellcheck) {
105
+ printShellcheckInstallHints()
106
+ return 1
107
+ }
108
+ const patchBin = resolveCmd('patch')
109
+ if (!patchBin) {
110
+ printPatchInstallHints()
111
+ return 1
112
+ }
113
+
114
+ const files = listShellScriptPaths(root)
115
+ if (files.length === 0) {
116
+ return 0
117
+ }
118
+
119
+ for (const rel of files) {
120
+ for (let round = 0; round < MAX_FIX_ROUNDS_PER_FILE; round++) {
121
+ const diffResult = spawnSync(shellcheck, ['-f', 'diff', rel], {
122
+ cwd: root,
123
+ encoding: 'utf8',
124
+ env: process.env,
125
+ maxBuffer: 10 * 1024 * 1024
126
+ })
127
+
128
+ if (diffResult.error) {
129
+ process.stderr.write(`${diffResult.error.message}\n`)
130
+ return 1
131
+ }
132
+
133
+ const code = diffResult.status ?? 1
134
+ const out = (diffResult.stdout ?? '').trim()
135
+ const err = (diffResult.stderr ?? '').trim()
136
+
137
+ if (code === 0) {
138
+ break
139
+ }
140
+
141
+ if (err.includes(NON_AUTOFIXABLE_HINT) || !out) {
142
+ break
143
+ }
144
+
145
+ const patchRun = spawnSync(patchBin, ['-p1'], {
146
+ cwd: root,
147
+ input: diffResult.stdout ?? '',
148
+ encoding: 'utf8',
149
+ env: process.env
150
+ })
151
+
152
+ if (patchRun.status !== 0) {
153
+ if (patchRun.stderr?.length) {
154
+ process.stderr.write(patchRun.stderr)
155
+ }
156
+ if (patchRun.stdout?.length) {
157
+ process.stderr.write(patchRun.stdout)
158
+ }
159
+ process.stderr.write(`run-shellcheck-text: patch не застосував diff для ${rel}\n`)
160
+ return 1
161
+ }
162
+ }
163
+ }
164
+
165
+ const finalRun = spawnSync(shellcheck, files, {
166
+ cwd: root,
167
+ encoding: 'utf8',
168
+ env: process.env,
169
+ maxBuffer: 10 * 1024 * 1024,
170
+ stdio: ['ignore', 'pipe', 'pipe']
171
+ })
172
+
173
+ if (finalRun.error) {
174
+ process.stderr.write(`${finalRun.error.message}\n`)
175
+ return 1
176
+ }
177
+
178
+ if (finalRun.status !== 0) {
179
+ if (finalRun.stdout?.length) {
180
+ process.stdout.write(finalRun.stdout)
181
+ }
182
+ if (finalRun.stderr?.length) {
183
+ process.stderr.write(finalRun.stderr)
184
+ }
185
+ return 1
186
+ }
187
+
188
+ return 0
189
+ }
190
+
191
+ if (isRunAsCli()) {
192
+ process.exitCode = runShellcheckText()
193
+ }