@nitra/cursor 1.8.179 → 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,48 @@
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
+
42
+ ## [1.8.180] - 2026-05-05
43
+
44
+ ### Changed
45
+
46
+ - `js-run` (mdc v1.1 → v1.2): додано секцію **«Область застосування»** — правило явно не застосовується до frontend-пакетів (маркер `vite` у `devDependencies`). У браузерному бандлі немає `node:process`, тому заміна `process.env.X` на `import { env } from 'node:process'` ламає рантайм (`TypeError: Cannot read properties of undefined (reading 'X')`); для frontend замість `process.env.NODE_ENV` — `import.meta.env.MODE` / `import.meta.env.PROD`, інші ENV — лише `import.meta.env.VITE_*`. Передумова — інцидент у abie/b2b `site/`, де LLM-агент за правилом замінив `process.env.NODE_ENV` у `src/main.js` і вибив прод-бандл.
47
+ - `check-js-run.mjs`: workspace-пакети з `vite` у `devDependencies` пропускаються — нова `packageJsonHasViteDevDependency(pkgJson)`, виклик одразу після `loadPackageJsonAndCheckBunyanDeps`. bunyan-залежність у `package.json` все одно перевіряється (бо це робиться до раннього виходу), але скан `process.env`, `#conn/*` і OTEL configmap для frontend-пакета не запускається. Тести: 2 нові кейси у `check-js-run-fixture.test.mjs` (vite-пакет з прямим `process.env` — pass; non-vite пакет з тим же кодом — fail).
48
+
7
49
  ## [1.8.179] - 2026-05-05
8
50
 
9
51
  ### Changed
@@ -48,9 +90,6 @@
48
90
  ### Added
49
91
 
50
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`.
51
-
52
- ### Added
53
-
54
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 отримав секцію «Виключення цілих дерев».
55
94
  - `scripts/utils/load-cursor-config.mjs`: нова утиліта `loadCursorIgnorePaths(root)` — читає поле `ignore` з `.n-cursor.json` і нормалізує до абсолютних posix-шляхів без trailing-slash; пропускає не-рядки та порожні елементи; повертає `[]`, якщо файлу/поля нема або JSON невалідний.
56
95
  - `scripts/utils/walkDir.mjs`: третій аргумент `ignorePaths` (за замовчуванням `[]`) — каталоги, які пропускаються разом з усім вмістом. Збіг — за повним шляхом (точний або з префіксом `/`), а не за basename, тож `postgres-master-test/` не пропускається коли в ignore лише `postgres-master/`. Стандартні пропуски (`node_modules`, `.git`, `dist`, `coverage`, `.turbo`, `.next`) працюють як раніше.
@@ -147,8 +186,8 @@
147
186
 
148
187
  ### Changed
149
188
 
150
- - `js-bun-db.mdc` (v1.4): `sql.unsafe(...)` тепер заборонено за замовчуванням — допустимо лише для підстановки назви таблиці/колонки чи dynamic SQL/DDL з code-controlled значенням; інакше переробляємо на tagged template `sql\`...${value}...\``. Кожен легітимний виклик має супроводжуватись маркером `// allow-unsafe: <причина>` на тому ж рядку або рядком вище.
151
- - `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-коментар на тому ж рядку чи безпосередньо перед викликом).
152
191
  - `ast-scan-utils.mjs`: додано `parseProgramAndCommentsOrNull` — окремий вхід для перевірок, яким потрібні коментарі поряд з AST.
153
192
 
154
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,9 +1,21 @@
1
1
  ---
2
2
  description: Це правила для backend проектів на JavaScript/Node.js, сюди входять і job і WEB сервери.
3
3
  alwaysApply: true
4
- version: '1.1'
4
+ version: '1.3'
5
5
  ---
6
6
 
7
+ ## Область застосування
8
+
9
+ Правило стосується **виключно backend Node.js workspace-пакетів** (jobs, GraphQL/HTTP-сервери, CLI). **Не застосовується** до frontend-пакетів, які бандляться в браузер: маркер — наявність `vite` у `devDependencies` пакета (`site/`, мобільні Capacitor-пакети, будь-яка Vue/Quasar SPA).
10
+
11
+ У браузерному середовищі:
12
+
13
+ - немає `node:process` — імпорт `import { env } from 'node:process'` resolve'иться у `undefined`, і `env.X` падає з `TypeError: Cannot read properties of undefined`;
14
+ - `process.env.X` у джерелах пакета відсутнє в рантаймі — Vite або взагалі не підставляє його, або підставляє лише `process.env.NODE_ENV`;
15
+ - усі змінні оточення для frontend задаються через `VITE_*` і доступні як `import.meta.env.VITE_X` (типобезпечно через `vite-check-env`); режим — `import.meta.env.MODE` / `import.meta.env.PROD`.
16
+
17
+ Тому **у frontend-пакетах не торкайся `process.env.*`** і **не додавай** `import { env } from 'node:process'`. Якщо натрапив на `process.env.NODE_ENV` у frontend-коді — заміна, якщо взагалі потрібна, лише на `import.meta.env.MODE`.
18
+
7
19
  ## Структура проекту
8
20
 
9
21
  Рекомендується використовувати таку структуру проекту:
@@ -108,6 +120,8 @@ export const db = new SQL({ url: env.PG_CONN })
108
120
 
109
121
  Прямий доступ до `process.env.X` у коді заборонений — його треба замінити на `env`:
110
122
 
123
+ > Стосується лише backend-пакетів (див. **Область застосування**). У frontend-пакетах (`vite` у `devDependencies`) — **не змінюй** `process.env.*` і **не додавай** імпорт `node:process`.
124
+
111
125
  - **обов'язкова змінна** — `import { checkEnv, env } from '@nitra/check-env'` плюс `checkEnv(['X'])`
112
126
  у тому ж файлі (приклад див. вище в розділі **CheckEnv**);
113
127
  - **опційна змінна** — `import { env } from 'node:process'`:
@@ -122,6 +136,26 @@ console.log(env.OPTIONAL_ENV_VAR)
122
136
  `// @nitra/cursor ignore-next-line checkEnv` безпосередньо перед використанням
123
137
  (escape-hatch для legacy-коду, не для нових файлів).
124
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
+
125
159
  ## Перевірка
126
160
 
127
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.179",
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
  */
@@ -66,7 +66,7 @@ export function isDockerfileName(name) {
66
66
  /**
67
67
  * Збирає абсолютні шляхи до Dockerfile / Containerfile від кореня cwd.
68
68
  * @param {string} root корінь репозиторію
69
- * @param {string[]} [ignorePaths=[]] шляхи каталогів, повністю виключених з обходу
69
+ * @param {string[]} [ignorePaths] шляхи каталогів, повністю виключених з обходу
70
70
  * @returns {Promise<string[]>} відсортовані абсолютні шляхи
71
71
  */
72
72
  export async function findDockerfilePaths(root, ignorePaths = []) {
@@ -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)
@@ -32,6 +32,7 @@ export const REQUIRED_DUMP_SCHEMA_SCRIPT =
32
32
  /**
33
33
  * Збирає абсолютні шляхи source-файлів, які підлягають скануванню на gql templates.
34
34
  * @param {string} root абсолютний шлях кореня
35
+ * @param {string[]} ignorePaths абсолютні шляхи каталогів, повністю виключених з обходу
35
36
  * @returns {Promise<string[]>} список кандидатів
36
37
  */
37
38
  async function collectScanCandidates(root, ignorePaths) {
@@ -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) {