@nitra/cursor 1.8.180 → 1.8.184

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,41 @@
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.184] - 2026-05-06
8
+
9
+ ### Added
10
+
11
+ - `check-js-run.mjs`: програмна перевірка нового правила «depcheck у GitHub Actions з path-фільтром». Для кожного backend workspace-пакета сканується `.github/workflows/*.yml`; якщо `on.push.paths` або `on.pull_request.paths` містить glob, що починається з `<rootDir>/`, у job очікується крок `npx depcheck` з `working-directory: <rootDir>` і `--ignores`, що містить мінімум `graphql,bun` (інші значення допустимі). Логіка — у новому `scripts/utils/depcheck-workflow.mjs` (парсинг `--ignores="…"` з підтримкою single/double-quote і unquoted формату; класифікація `missing` / `wrong-cwd` / `missing-ignores`).
12
+ - `check-js-run-fixture.test.mjs`: 9 нових кейсів — нема `.github/workflows/`, глобальні paths без скоупу пакета, scoped-paths без depcheck (fail), depcheck з неправильним `working-directory` (fail), без `--ignores` (fail), `--ignores` без `bun` (fail), валідний з extra-ignores (pass), вкладений `cron-jobs/foo/src/**` як scope (pass).
13
+ - `.github/workflows/npm-publish.yml`: додано власний крок `npx depcheck --ignores="graphql,bun,bun:test,@nitra/cursor"` з `working-directory: npm`, щоб репо `@nitra/cursor` саме відповідало новому правилу js-run (`paths: ['npm/**']` обмежено пакетом `npm`); extra-ignores потрібні для self-reference `@nitra/cursor` у devDependencies та для `bun:test` як bun-built-in.
14
+
15
+ ## [1.8.183] - 2026-05-06
16
+
17
+ ### Changed
18
+
19
+ - `ga` (mdc v1.6 → v1.7): додано **універсальну** вимогу — кожен workflow у `.github/workflows/*.yml` обов'язково містить блок `concurrency` з `group: ${{ github.ref }}-${{ github.workflow }}` і `cancel-in-progress: true`. Без винятків — scheduled cleanup-воркфлоу, `pull_request: types: [closed]`, publish-воркфлоу теж. Канонічні приклади у правилі (`clean-ga-workflows.yml`, `clean-merged-branch.yml`, `git-ai.yml`) оновлено й тепер містять цей блок.
20
+ - `check-ga.mjs`: нова перевірка `verifyConcurrencyBlock` — запускається на кожному `*.yml` у `.github/workflows/` і структурно перевіряє рівно два поля (`concurrency.group` дорівнює канонічному рядку, `concurrency.cancel-in-progress === true`); відсутність блоку, інший `group` або `cancel-in-progress: false` — fail. Спільний `validateConcurrencyOnRoot` додано в усі канонічні структурні валідатори (clean-ga-workflows, clean-merged-branch, lint-ga, git-ai), щоб ці workflow перевірялися й через шаблонну, і через універсальну логіку.
21
+
22
+ ## [1.8.182] - 2026-05-06
23
+
24
+ ### Changed
25
+
26
+ - `js-run` (mdc v1.2 → v1.3): додано секцію **«depcheck у GitHub Actions з path-фільтром»** — якщо в `.github/workflows/*.yml` тригер `paths:` обмежено каталогом одного backend-пакета (наприклад `cron-jobs/refund-loyalty-points/**`), у job має бути крок `npx depcheck --ignores="graphql,bun"` з `working-directory`, що вказує на той самий каталог. Список `--ignores` обов'язково містить мінімум `graphql,bun` (peer-залежність GraphQL та рантайм Bun, які depcheck не розпізнає коректно), але може бути розширений значеннями через кому без пробілів. Не застосовується до глобальних workflow без `paths:` або з кореневими `**/*.js` патернами.
27
+
28
+ ## [1.8.181] - 2026-05-06
29
+
30
+ ### Changed
31
+
32
+ - `scripts/utils/find-package-json-paths.mjs`: винесено спільну `findAllPackageJsonPaths(repoRoot, ignorePaths)` з `check-js-bun-db.mjs` і `check-js-mssql.mjs`, щоб усунути jscpd-дублювання. Самі check-скрипти тепер імпортують її, як інші утиліти з `utils/`.
33
+ - `scripts/utils/walkDir.mjs` / `scripts/utils/load-cursor-config.mjs`: trimming trailing-slash переписано з регулярки `/\\/+$/` на `while (s.endsWith('/'))`, щоб уникнути попередження `sonarjs/slow-regex` (потенційний backtracking) — поведінка не змінилась.
34
+ - `scripts/check-k8s.mjs`: `failIfExplicitPatchTargetsHaveRedundantGroupVersion` рефакторено — логіка одного запису винесена в новий хелпер `describePatchTargetRedundancy`, основна функція тепер просто будує повідомлення з результату (зменшено sonarjs/cognitive-complexity 24→<15, поведінка не змінилась).
35
+ - `scripts/claude-stop-hook.mjs`: `readStdin` і `runStopHookCli` переписані з `new Promise(resolve => …)` на `events.once(stream, 'end' | 'exit')` — підказка `eslint-plugin-promise/avoid-new`, поведінка не змінилась.
36
+
37
+ ### Fixed
38
+
39
+ - `scripts/utils/bun-sql-scan.mjs`: у JSDoc `findBunSqlUnsafeUseWithoutAllowMarkerInText` прибрано вкладений приклад із backslash-backtick (`sql\\`...\\${value}...\\``), який ламав парсер коментарів oxlint і призводив до false-positive `eslint-plugin-jsdoc(require-param)`/`(require-returns)` на функції з валідним JSDoc — текст переписано без екранованих backtick-ів.
40
+ - Десятки `eslint-plugin-jsdoc` правил у `npm/scripts/**` та `npm/tests/**`: додано відсутні описи `@param` / `@returns` (включно зі спільним `ignorePaths`-аргументом у нових сигнатурах walkDir-обгорток), прибрано неприпустимі дефолтні значення в JSDoc (`[name=...]`) — без зміни поведінки.
41
+
7
42
  ## [1.8.180] - 2026-05-05
8
43
 
9
44
  ### Changed
@@ -55,9 +90,6 @@
55
90
  ### Added
56
91
 
57
92
  - Нове правило `changelog` (`mdc/changelog.mdc` + `scripts/check-changelog.mjs`): для «звичайних» Bun-монорепо проєктів вимагає, щоб у кожному workspace, який змінився відносно базової гілки `dev`, у поточному PR було підвищено `version` у `<ws>/package.json` і додано запис `## [version] - YYYY-MM-DD` у `<ws>/CHANGELOG.md` (Keep a Changelog 1.1.0). Перевірка PR-scoped: на самій гілці `dev` пропускається; на feature-гілці bump і запис достатньо зробити **один раз — як суму по всьому PR**, без бамп-шуму в проміжних комітах. Воркспейс `npm/` пропускається — його CHANGELOG покриває окреме правило `npm-module`. У `auto-rules.md` / `auto-rules.mjs` `changelog` додано до автодетекту з умовою «у корені є `package.json`» і до `AUTO_RULE_ORDER` між `capacitor` і `docker`.
58
-
59
- ### Added
60
-
61
93
  - `.n-cursor.json` поле `ignore` (`schemas/n-cursor.json`): тепер не лише сигнал для AI, а й керує обходом усіх `check-*.mjs` / `run-*.mjs` — перелічені каталоги повністю виключаються з `walkDir`, як `node_modules` чи `.git`. Дозволяє безпечно тримати vendored Helm-чарти, генеровані маніфести, legacy-дерева у репо без false-positive’ів від check-скриптів. Розширено опис у схемі (стандартні виключення додавати не треба) і README отримав секцію «Виключення цілих дерев».
62
94
  - `scripts/utils/load-cursor-config.mjs`: нова утиліта `loadCursorIgnorePaths(root)` — читає поле `ignore` з `.n-cursor.json` і нормалізує до абсолютних posix-шляхів без trailing-slash; пропускає не-рядки та порожні елементи; повертає `[]`, якщо файлу/поля нема або JSON невалідний.
63
95
  - `scripts/utils/walkDir.mjs`: третій аргумент `ignorePaths` (за замовчуванням `[]`) — каталоги, які пропускаються разом з усім вмістом. Збіг — за повним шляхом (точний або з префіксом `/`), а не за basename, тож `postgres-master-test/` не пропускається коли в ignore лише `postgres-master/`. Стандартні пропуски (`node_modules`, `.git`, `dist`, `coverage`, `.turbo`, `.next`) працюють як раніше.
@@ -154,8 +186,8 @@
154
186
 
155
187
  ### Changed
156
188
 
157
- - `js-bun-db.mdc` (v1.4): `sql.unsafe(...)` тепер заборонено за замовчуванням — допустимо лише для підстановки назви таблиці/колонки чи dynamic SQL/DDL з code-controlled значенням; інакше переробляємо на tagged template `sql\`...${value}...\``. Кожен легітимний виклик має супроводжуватись маркером `// allow-unsafe: <причина>` на тому ж рядку або рядком вище.
158
- - `check-js-bun-db.mjs`: замість вузької перевірки `sql.unsafe(\`...${expr}...\`)` тепер сканер `findBunSqlUnsafeUseWithoutAllowMarkerInText` падає на будь-якому `<obj>.unsafe(...)` без маркера-коментаря з непорожньою причиною (line- або block-коментар на тому ж рядку чи безпосередньо перед викликом).
189
+ - `js-bun-db.mdc` (v1.4): `sql.unsafe(...)` тепер заборонено за замовчуванням — допустимо лише для підстановки назви таблиці/колонки чи dynamic SQL/DDL з code-controlled значенням; інакше переробляємо на tagged template `sql\`...${value}...\``. Кожен легітимний виклик має супроводжуватись маркером`// allow-unsafe: <причина>` на тому ж рядку або рядком вище.
190
+ - `check-js-bun-db.mjs`: замість вузької перевірки `sql.unsafe` із tagged-template і інтерполяцією тепер сканер `findBunSqlUnsafeUseWithoutAllowMarkerInText` падає на будь-якому `obj.unsafe(...)` без маркера-коментаря з непорожньою причиною (line- або block-коментар на тому ж рядку чи безпосередньо перед викликом).
159
191
  - `ast-scan-utils.mjs`: додано `parseProgramAndCommentsOrNull` — окремий вхід для перевірок, яким потрібні коментарі поряд з AST.
160
192
 
161
193
  ## [1.8.159] - 2026-05-01
package/mdc/ga.mdc CHANGED
@@ -1,11 +1,21 @@
1
1
  ---
2
2
  description: Правила форматів для .github/workflows
3
3
  alwaysApply: true
4
- version: '1.6'
4
+ version: '1.7'
5
5
  ---
6
6
 
7
7
  У `.github/workflows/` лише **`.yml`**. Мають бути **`clean-ga-workflows.yml`**, **`clean-merged-branch.yml`**, **`lint-ga.yml`**, **`git-ai.yml`**. Якщо є **`apply-k8s.yml`** / **`apply-nats-consumer.yml`** — paths у тригері як у фрагментах.
8
8
 
9
+ **Кожен** workflow у `.github/workflows/*.yml` **обов'язково** містить блок `concurrency` з фіксованим `group` та `cancel-in-progress: true`:
10
+
11
+ ```yaml
12
+ concurrency:
13
+ group: ${{ github.ref }}-${{ github.workflow }}
14
+ cancel-in-progress: true
15
+ ```
16
+
17
+ Без винятків — у scheduled cleanup-воркфлоу, у `pull_request: types: [closed]`, у publish-воркфлоу теж. Це уникає паралельних запусків того самого workflow на тій самій ref і скасовує попередні в чергу нових.
18
+
9
19
  Повинен бути файл .github/workflows/clean-ga-workflows.yml, зі змістом:
10
20
 
11
21
  ```yaml
@@ -18,6 +28,10 @@ on:
18
28
  # Allow workflow to be manually run from the GitHub UI
19
29
  workflow_dispatch: {}
20
30
 
31
+ concurrency:
32
+ group: ${{ github.ref }}-${{ github.workflow }}
33
+ cancel-in-progress: true
34
+
21
35
  jobs:
22
36
  cleanup_old_workflows:
23
37
  runs-on: ubuntu-latest
@@ -47,6 +61,10 @@ on:
47
61
  # Allow workflow to be manually run from the GitHub UI
48
62
  workflow_dispatch: {}
49
63
 
64
+ concurrency:
65
+ group: ${{ github.ref }}-${{ github.workflow }}
66
+ cancel-in-progress: true
67
+
50
68
  jobs:
51
69
  cleanup_old_branches:
52
70
  runs-on: ubuntu-latest
@@ -119,6 +137,10 @@ on:
119
137
  pull_request:
120
138
  types: [closed]
121
139
 
140
+ concurrency:
141
+ group: ${{ github.ref }}-${{ github.workflow }}
142
+ cancel-in-progress: true
143
+
122
144
  jobs:
123
145
  git-ai:
124
146
  if: github.event.pull_request.merged == true
package/mdc/js-run.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Це правила для backend проектів на JavaScript/Node.js, сюди входять і job і WEB сервери.
3
3
  alwaysApply: true
4
- version: '1.2'
4
+ version: '1.3'
5
5
  ---
6
6
 
7
7
  ## Область застосування
@@ -136,6 +136,26 @@ console.log(env.OPTIONAL_ENV_VAR)
136
136
  `// @nitra/cursor ignore-next-line checkEnv` безпосередньо перед використанням
137
137
  (escape-hatch для legacy-коду, не для нових файлів).
138
138
 
139
+ ## depcheck у GitHub Actions з path-фільтром
140
+
141
+ Якщо в `.github/workflows/*.yml` є тригер з `paths:`, який обмежує запуск workflow змінами в каталозі конкретного backend-пакета, в job цього workflow має бути крок `npx depcheck` з `working-directory`, який вказує на той самий каталог пакета. Це гарантує, що декларація залежностей у `package.json` пакета відповідає реальним імпортам — інакше можна випадково зламати білд після видалення «зайвої» залежності, яка насправді використовується через побічний імпорт.
142
+
143
+ Список `--ignores` **обов'язково** містить як мінімум `graphql,bun` (це ті, які `depcheck` не вміє коректно розпізнавати: `graphql` — peer-залежність, що часто використовується без прямого імпорту в коді; `bun` — рантайм, не npm-пакет). За потреби список можна розширити іншими модулями, специфічними для пакета — список значень розділяється комою без пробілів.
144
+
145
+ ```yaml title="Приклад: workflow для cron-jobs/refund-loyalty-points"
146
+ on:
147
+ push:
148
+ paths:
149
+ - 'cron-jobs/refund-loyalty-points/**'
150
+
151
+ # …
152
+
153
+ - run: npx depcheck --ignores="graphql,bun"
154
+ working-directory: cron-jobs/refund-loyalty-points
155
+ ```
156
+
157
+ Правило не застосовується до workflow без `paths:` або з `paths:`, який не звужує тригер до одного backend-пакета (наприклад, кореневі `lint-*.yml` з глобальними `**/*.js`).
158
+
139
159
  ## Перевірка
140
160
 
141
161
  `npx @nitra/cursor check js-run`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.180",
3
+ "version": "1.8.184",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -44,11 +44,11 @@
44
44
  "oxc-parser": "^0.128.0",
45
45
  "yaml": "^2.8.3"
46
46
  },
47
+ "devDependencies": {
48
+ "@nitra/cursor": "^1.8.170"
49
+ },
47
50
  "engines": {
48
51
  "bun": ">=1.3",
49
52
  "node": ">=25"
50
- },
51
- "devDependencies": {
52
- "@nitra/cursor": "^1.8.170"
53
53
  }
54
54
  }
@@ -384,6 +384,7 @@ export function ignoreBranchesIncludesRequired(ignoreBranches, required) {
384
384
  /**
385
385
  * Збирає абсолютні шляхи до **.yaml** / **.yml** під деревом, де є сегмент **k8s**.
386
386
  * @param {string} root корінь репозиторію
387
+ * @param {string[]} [ignorePaths] абсолютні шляхи каталогів, повністю виключених з обходу
387
388
  * @returns {Promise<string[]>} відсортовані шляхи
388
389
  */
389
390
  async function findK8sYamlFiles(root, ignorePaths = []) {
@@ -41,7 +41,7 @@ const NPM_VIEW_TIMEOUT_MS = 10_000
41
41
  /**
42
42
  * Тихо запускає `git` і повертає stdout або `null` при будь-якій помилці.
43
43
  * @param {string[]} args аргументи `git`
44
- * @returns {Promise<string | null>}
44
+ * @returns {Promise<string | null>} stdout процесу або `null` при будь-якій помилці виконання
45
45
  */
46
46
  async function gitOrNull(args) {
47
47
  try {
@@ -54,7 +54,7 @@ async function gitOrNull(args) {
54
54
 
55
55
  /**
56
56
  * Чи робочий каталог — git-репозиторій.
57
- * @returns {Promise<boolean>}
57
+ * @returns {Promise<boolean>} `true`, якщо `git rev-parse --is-inside-work-tree` повернув `true`
58
58
  */
59
59
  async function isInsideGitRepo() {
60
60
  const out = await gitOrNull(['rev-parse', '--is-inside-work-tree'])
@@ -63,7 +63,7 @@ async function isInsideGitRepo() {
63
63
 
64
64
  /**
65
65
  * Назва поточної гілки (або `HEAD` для detached state).
66
- * @returns {Promise<string | null>}
66
+ * @returns {Promise<string | null>} назва гілки чи `'HEAD'`, або `null` (поза git / помилка)
67
67
  */
68
68
  async function currentBranchName() {
69
69
  const out = await gitOrNull(['rev-parse', '--abbrev-ref', 'HEAD'])
@@ -73,7 +73,7 @@ async function currentBranchName() {
73
73
  /**
74
74
  * Знаходить ref для базової гілки. Перевага локальному `dev`, далі `origin/dev`. Повертає `null`,
75
75
  * якщо жоден не існує.
76
- * @returns {Promise<string | null>}
76
+ * @returns {Promise<string | null>} назва ref-а (`dev` чи `origin/dev`) або `null`, якщо жоден не знайдено
77
77
  */
78
78
  async function resolveBaseRef() {
79
79
  for (const ref of [BASE_BRANCH, `origin/${BASE_BRANCH}`]) {
@@ -88,8 +88,8 @@ async function resolveBaseRef() {
88
88
  /**
89
89
  * Точка розгалуження поточної гілки від `baseRef`. На feature-гілці = коли вона відгалузилась;
90
90
  * на `main` після merge `dev → main` = поточний `dev`. Повертає `null`, якщо merge-base нема.
91
- * @param {string} baseRef
92
- * @returns {Promise<string | null>}
91
+ * @param {string} baseRef SHA або ref-name бази (зазвичай `dev` / `origin/dev`)
92
+ * @returns {Promise<string | null>} SHA точки розгалуження або `null`, якщо merge-base нема
93
93
  */
94
94
  async function resolveMergeBase(baseRef) {
95
95
  const out = await gitOrNull(['merge-base', baseRef, 'HEAD'])
@@ -104,9 +104,9 @@ async function resolveMergeBase(baseRef) {
104
104
  * Для кореня `.` — це точка плюс magic-виключення кожного підворкспейсу через `:(exclude)<sub>/`,
105
105
  * щоб зміни всередині sub-workspace не вважалися змінами кореня.
106
106
  * Для звичайного воркспейсу — просто `<ws>/`.
107
- * @param {string} ws
108
- * @param {string[]} subWorkspaces
109
- * @returns {string[]}
107
+ * @param {string} ws шлях воркспейсу (`'.'` для кореня, інакше — відносний шлях, як у `workspaces`)
108
+ * @param {string[]} subWorkspaces усі під-воркспейси (зокрема для `'.'` потрібно виключити їх)
109
+ * @returns {string[]} pathspec для git: масив, що передається після `--`
110
110
  */
111
111
  function pathspecForWorkspace(ws, subWorkspaces) {
112
112
  if (ws !== '.') return [`${ws}/`]
@@ -119,9 +119,9 @@ function pathspecForWorkspace(ws, subWorkspaces) {
119
119
  * `git diff --quiet <baseRef> -- <pathspec>` ловить committed-зміни на цій гілці й незбережені
120
120
  * правки tracked-файлів. Untracked-файли — `git ls-files --others --exclude-standard`.
121
121
  * @param {string} baseRef SHA або ref-name (зокрема merge-base)
122
- * @param {string} ws
123
- * @param {string[]} subWorkspaces
124
- * @returns {Promise<boolean>}
122
+ * @param {string} ws шлях воркспейсу (`'.'` для кореня)
123
+ * @param {string[]} subWorkspaces усі під-воркспейси для коректного формування pathspec кореня
124
+ * @returns {Promise<boolean>} `true`, якщо в межах воркспейсу є будь-які зміни (committed або untracked)
125
125
  */
126
126
  async function workspaceHasChangesAgainstBase(baseRef, ws, subWorkspaces) {
127
127
  const pathspec = pathspecForWorkspace(ws, subWorkspaces)
@@ -129,8 +129,7 @@ async function workspaceHasChangesAgainstBase(baseRef, ws, subWorkspaces) {
129
129
  await execFileAsync('git', ['diff', '--quiet', baseRef, '--', ...pathspec])
130
130
  } catch (error) {
131
131
  const code = /** @type {{ code?: number }} */ (error).code
132
- if (code === 1) return true
133
- return false
132
+ return code === 1
134
133
  }
135
134
  const untracked = await gitOrNull(['ls-files', '--others', '--exclude-standard', '--', ...pathspec])
136
135
  return typeof untracked === 'string' && untracked.trim().length > 0
@@ -138,9 +137,9 @@ async function workspaceHasChangesAgainstBase(baseRef, ws, subWorkspaces) {
138
137
 
139
138
  /**
140
139
  * Версія з `<ws>/package.json` на `baseRef` або `null`.
141
- * @param {string} baseRef
142
- * @param {string} ws
143
- * @returns {Promise<string | null>}
140
+ * @param {string} baseRef SHA або ref-name (зазвичай merge-base) для `git show`
141
+ * @param {string} ws шлях воркспейсу (`'.'` для кореня)
142
+ * @returns {Promise<string | null>} значення поля `version` або `null`, якщо файла нема / JSON некоректний
144
143
  */
145
144
  async function readBaseVersion(baseRef, ws) {
146
145
  const wsPath = ws === '.' ? 'package.json' : `${ws}/package.json`
@@ -156,9 +155,9 @@ async function readBaseVersion(baseRef, ws) {
156
155
 
157
156
  /**
158
157
  * Чи містить текст `CHANGELOG.md` запис `## [version]` (з опційним `- YYYY-MM-DD`).
159
- * @param {string} text
160
- * @param {string} version
161
- * @returns {boolean}
158
+ * @param {string} text вміст CHANGELOG.md
159
+ * @param {string} version версія, яку шукаємо у форматі Keep a Changelog
160
+ * @returns {boolean} `true`, якщо запис для `version` знайдено
162
161
  */
163
162
  function changelogHasVersionEntry(text, version) {
164
163
  const escaped = version.replaceAll(/[.+*?^$()[\]{}|\\]/g, String.raw`\$&`)
@@ -168,8 +167,8 @@ function changelogHasVersionEntry(text, version) {
168
167
 
169
168
  /**
170
169
  * Зчитує `<ws>/package.json`. `null`, якщо файл відсутній або JSON некоректний.
171
- * @param {string} ws
172
- * @returns {Promise<Record<string, unknown> | null>}
170
+ * @param {string} ws шлях воркспейсу (`'.'` для кореня)
171
+ * @returns {Promise<Record<string, unknown> | null>} розпарсений `package.json` або `null`
173
172
  */
174
173
  async function readPackageJsonOrNull(ws) {
175
174
  const path = join(ws, 'package.json')
@@ -186,8 +185,8 @@ async function readPackageJsonOrNull(ws) {
186
185
 
187
186
  /**
188
187
  * Воркспейс публікується в npm: має непорожній `name`, не `private: true`, і має масив `files`.
189
- * @param {Record<string, unknown> | null} pkg
190
- * @returns {boolean}
188
+ * @param {Record<string, unknown> | null} pkg розпарсений `package.json` (або `null`)
189
+ * @returns {boolean} `true`, якщо пакет придатний для публікації в npm
191
190
  */
192
191
  function isNpmPublishable(pkg) {
193
192
  if (!pkg) return false
@@ -199,8 +198,8 @@ function isNpmPublishable(pkg) {
199
198
  /**
200
199
  * Опублікована версія пакета в npm-реєстрі. `null` — пакет не знайдено / нема мережі / помилка.
201
200
  * Дефолтна імплементація — `npm view <name> version` із таймаутом, щоб не блокуватись офлайн.
202
- * @param {string} name
203
- * @returns {Promise<string | null>}
201
+ * @param {string} name повна назва пакета (включно зі скоупом)
202
+ * @returns {Promise<string | null>} опублікована версія або `null` (нема пакета / офлайн)
204
203
  */
205
204
  async function defaultGetPublishedVersion(name) {
206
205
  try {
@@ -214,10 +213,10 @@ async function defaultGetPublishedVersion(name) {
214
213
 
215
214
  /**
216
215
  * Перевіряє масив `files` у `<ws>/package.json`: якщо оголошено — має містити `"CHANGELOG.md"`.
217
- * @param {Record<string, unknown> | null} pkg
218
- * @param {string} ws
219
- * @param {(msg: string) => void} pass
220
- * @param {(msg: string) => void} fail
216
+ * @param {Record<string, unknown> | null} pkg розпарсений `package.json` воркспейсу
217
+ * @param {string} ws шлях воркспейсу (`'.'` для кореня)
218
+ * @param {(msg: string) => void} pass callback при успішній перевірці
219
+ * @param {(msg: string) => void} fail callback при помилці
221
220
  */
222
221
  function checkFilesArrayContainsChangelog(pkg, ws, pass, fail) {
223
222
  if (!pkg || !Array.isArray(pkg.files)) return
@@ -231,10 +230,10 @@ function checkFilesArrayContainsChangelog(pkg, ws, pass, fail) {
231
230
 
232
231
  /**
233
232
  * Перевіряє наявність запису у `<ws>/CHANGELOG.md` для версії `version`.
234
- * @param {string} ws
235
- * @param {string} version
236
- * @param {(msg: string) => void} pass
237
- * @param {(msg: string) => void} fail
233
+ * @param {string} ws шлях воркспейсу (`'.'` для кореня)
234
+ * @param {string} version версія, для якої очікується запис
235
+ * @param {(msg: string) => void} pass callback при успішній перевірці
236
+ * @param {(msg: string) => void} fail callback при помилці
238
237
  * @returns {Promise<boolean>} `false`, якщо файл відсутній або немає запису
239
238
  */
240
239
  async function verifyChangelogEntry(ws, version, pass, fail) {
@@ -257,11 +256,11 @@ async function verifyChangelogEntry(ws, version, pass, fail) {
257
256
  * npm-published режим: порівнює локальну `version` з опублікованою в реєстрі. Якщо вони
258
257
  * відрізняються — вимагає запис у CHANGELOG і `"CHANGELOG.md"` у `files`. Якщо реєстр недосяжний,
259
258
  * правило fail-safe пасує (щоб офлайн-розробка не блокувалась).
260
- * @param {string} ws
261
- * @param {Record<string, unknown>} pkg
262
- * @param {(name: string) => Promise<string | null>} getPublishedVersion
263
- * @param {(msg: string) => void} pass
264
- * @param {(msg: string) => void} fail
259
+ * @param {string} ws шлях воркспейсу (`'.'` для кореня)
260
+ * @param {Record<string, unknown>} pkg розпарсений `package.json` воркспейсу
261
+ * @param {(name: string) => Promise<string | null>} getPublishedVersion стаб/реальна функція отримання опублікованої версії
262
+ * @param {(msg: string) => void} pass callback при успішній перевірці
263
+ * @param {(msg: string) => void} fail callback при помилці
265
264
  */
266
265
  async function checkPublishedWorkspace(ws, pkg, getPublishedVersion, pass, fail) {
267
266
  const label = ws === '.' ? '<root>' : ws
@@ -289,10 +288,10 @@ async function checkPublishedWorkspace(ws, pkg, getPublishedVersion, pass, fail)
289
288
  * local-only режим: PR-scoped перевірка проти `dev` через `git merge-base`. Викликається лише
290
289
  * для воркспейсів, де є реальні зміни щодо merge-base.
291
290
  * @param {string} mergeBase SHA точки розгалуження
292
- * @param {string} ws
293
- * @param {Record<string, unknown> | null} pkg
294
- * @param {(msg: string) => void} pass
295
- * @param {(msg: string) => void} fail
291
+ * @param {string} ws шлях воркспейсу (`'.'` для кореня)
292
+ * @param {Record<string, unknown> | null} pkg розпарсений `package.json` воркспейсу (або `null`)
293
+ * @param {(msg: string) => void} pass callback при успішній перевірці
294
+ * @param {(msg: string) => void} fail callback при помилці
296
295
  */
297
296
  async function checkLocalOnlyChangedWorkspace(mergeBase, ws, pkg, pass, fail) {
298
297
  const label = ws === '.' ? '<root>' : ws
@@ -315,12 +314,12 @@ async function checkLocalOnlyChangedWorkspace(mergeBase, ws, pkg, pass, fail) {
315
314
 
316
315
  /**
317
316
  * Виконує local-only перевірку для всіх workspace-ів, у яких немає npm-published режиму.
318
- * @param {string[]} localOnlyWorkspaces
319
- * @param {Map<string, Record<string, unknown> | null>} pkgByWs
320
- * @param {string[]} subWorkspaces
321
- * @param {(msg: string) => void} pass
322
- * @param {(msg: string) => void} fail
323
- * @returns {Promise<void>}
317
+ * @param {string[]} localOnlyWorkspaces список шляхів local-only воркспейсів
318
+ * @param {Map<string, Record<string, unknown> | null>} pkgByWs мапа: шлях воркспейсу → розпарсений `package.json` (або `null`)
319
+ * @param {string[]} subWorkspaces усі під-воркспейси (для коректного pathspec кореня)
320
+ * @param {(msg: string) => void} pass callback при успішній перевірці
321
+ * @param {(msg: string) => void} fail callback при помилці
322
+ * @returns {Promise<void>} резолвиться по завершенню перевірок усіх local-only воркспейсів
324
323
  */
325
324
  async function runLocalOnlyChecks(localOnlyWorkspaces, pkgByWs, subWorkspaces, pass, fail) {
326
325
  if (localOnlyWorkspaces.length === 0) return
@@ -358,7 +357,7 @@ async function runLocalOnlyChecks(localOnlyWorkspaces, pkgByWs, subWorkspaces, p
358
357
 
359
358
  /**
360
359
  * Перевіряє відповідність проєкту правилу changelog.mdc.
361
- * @param {object} [opts]
360
+ * @param {object} [opts] опції перевірки
362
361
  * @param {(name: string) => Promise<string | null>} [opts.getPublishedVersion] перевизначення для тестів
363
362
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
364
363
  */
@@ -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) {
@@ -187,9 +187,7 @@ async function checkLegacyCacheRemoved(pass, fail) {
187
187
  }
188
188
  const lines = await readGitignoreLines()
189
189
  if (lines && lines.includes(LEGACY_CACHE_FILENAME)) {
190
- fail(
191
- `.gitignore: прибери застарілий рядок \`${LEGACY_CACHE_FILENAME}\` — split-cache 3.2.0 його не використовує`
192
- )
190
+ fail(`.gitignore: прибери застарілий рядок \`${LEGACY_CACHE_FILENAME}\` — split-cache 3.2.0 його не використовує`)
193
191
  return
194
192
  }
195
193
  pass(`${LEGACY_CACHE_FILENAME} відсутній (міграція на split-cache завершена)`)
@@ -203,7 +201,9 @@ async function checkLegacyCacheRemoved(pass, fail) {
203
201
  */
204
202
  function packageHasAvifDisabled(pkg) {
205
203
  const cfg = pkg[PKG_CONFIG_FIELD]
206
- return Boolean(cfg && typeof cfg === 'object' && /** @type {Record<string, unknown>} */ (cfg)['disable-avif'] === true)
204
+ return Boolean(
205
+ cfg && typeof cfg === 'object' && /** @type {Record<string, unknown>} */ (cfg)['disable-avif'] === true
206
+ )
207
207
  }
208
208
 
209
209
  /**
@@ -213,9 +213,10 @@ function packageHasAvifDisabled(pkg) {
213
213
  * один раз (інакше при обході кореня `.` ми б повторно зайшли в `demo/` і подвоїли звіти).
214
214
  * @param {string} packageRoot відносний шлях до кореня пакета (наприклад `'.'` або `'demo'`)
215
215
  * @param {string[]} otherRootsAbs абсолютні шляхи інших workspace-коренів — їх піддерева пропускаємо
216
+ * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
216
217
  * @param {(msg: string) => void} pass callback при успішній перевірці
217
218
  * @param {(msg: string) => void} fail callback при помилці
218
- * @returns {Promise<void>}
219
+ * @returns {Promise<void>} резолвиться по завершенню перевірки одного пакета
219
220
  */
220
221
  async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePaths, pass, fail) {
221
222
  const absRoot = join(process.cwd(), packageRoot)
@@ -263,9 +264,10 @@ async function checkVueAvifImportsInPackage(packageRoot, otherRootsAbs, ignorePa
263
264
  * Сканує всі workspace-пакети: для кожного перевіряє opt-out і за потреби викликає
264
265
  * перевірку Vue-imports. Перевірка пропускається, якщо в репозиторії немає workspaces
265
266
  * або немає `.vue`-файлів — тоді `image` правило не для цього проєкту.
267
+ * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
266
268
  * @param {(msg: string) => void} pass callback при успішній перевірці
267
269
  * @param {(msg: string) => void} fail callback при помилці
268
- * @returns {Promise<void>}
270
+ * @returns {Promise<void>} резолвиться по завершенню перевірки всіх workspace-пакетів
269
271
  */
270
272
  async function checkVueAvifImports(ignorePaths, pass, fail) {
271
273
  const roots = await getMonorepoPackageRootDirs()
@@ -275,7 +277,9 @@ async function checkVueAvifImports(ignorePaths, pass, fail) {
275
277
  if (!existsSync(pkgPath)) continue
276
278
  const pkg = JSON.parse(await readFile(pkgPath, 'utf8'))
277
279
  if (packageHasAvifDisabled(pkg)) {
278
- pass(`[${root === '.' ? 'корінь' : root}] avif-import enforcement вимкнено через "@nitra/minify-image.disable-avif"`)
280
+ pass(
281
+ `[${root === '.' ? 'корінь' : root}] avif-import enforcement вимкнено через "@nitra/minify-image.disable-avif"`
282
+ )
279
283
  continue
280
284
  }
281
285
  const otherRootsAbs = roots.filter(r => r !== root && r !== '.').map(r => absRootsByRel.get(r) ?? '')