@nitra/cursor 1.8.189 → 1.8.192

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,34 @@
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.192] - 2026-05-07
8
+
9
+ ### Added
10
+
11
+ - `run-shellcheck-text.mjs`: для `lint-text` — перевірка наявності `shellcheck`/`patch`, авто-виправлення через `shellcheck -f diff` + `patch -p1`, фінальний прогін по tracked `*.sh` (git) або `**/*.sh` без `node_modules`.
12
+ - `text` (mdc v1.25 → v1.26): **shellcheck** у ланцюжку `lint-text`, рекомендація **`timonwong.shellcheck`**, тригер workflow **`**/*.sh`**; тести `run-shellcheck-text.test.mjs`.
13
+
14
+ ### Changed
15
+
16
+ - `check-text.mjs`: `lint-text` має містити `run-shellcheck-text.mjs`; `extensions.json` — `timonwong.shellcheck`.
17
+
18
+ ## [1.8.191] - 2026-05-07
19
+
20
+ ### Added
21
+
22
+ - `check-npm-module.mjs`: перший заголовок **`## [version]`** у `npm/CHANGELOG.md` має збігатися з **`version`** у `npm/package.json` (найсвіжіший реліз зверху — Keep a Changelog); якщо є незакомічені зміни під **`npm/`**, `version` у робочому `npm/package.json` має відрізнятися від **`HEAD`** (інакше ризик дописати новий функціонал без bump).
23
+
24
+ ### Changed
25
+
26
+ - `npm-module` (mdc v1.9 → v1.10): розширено **«Build версія»** і **«CHANGELOG»** — чеклист для агента, заборона дописувати нові пункти в уже існуючу секцію релізу замість нового номера; впорядковано `CHANGELOG` (1.8.190 перед 1.8.189).
27
+
28
+ ## [1.8.190] - 2026-05-07
29
+
30
+ ### Added
31
+
32
+ - `js-run` (mdc v1.4 → v1.5): секція **`jsconfig.json`** — канонічний файл для backend-пакетів із каталогом **`src/`** (NodeNext, `include: ['src/**/*']`); для пакетів без `src/` вимога не діє.
33
+ - `check-js-run.mjs`: перевірка наявності та вмісту `jsconfig.json`, якщо в workspace-пакеті (без vite) є **`src/`**; тести у `check-js-run-fixture.test.mjs`.
34
+
7
35
  ## [1.8.189] - 2026-05-07
8
36
 
9
37
  ### Added
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.4'
4
+ version: '1.5'
5
5
  ---
6
6
 
7
7
  ## Область застосування
@@ -28,6 +28,25 @@ package.json
28
28
  readme.md
29
29
  ```
30
30
 
31
+ ## `jsconfig.json` (редактор / перевірка типів)
32
+
33
+ Якщо в **backend** workspace-пакеті (без `vite` у `devDependencies`) є каталог **`src/`**, у **корені цього пакета** має бути **`jsconfig.json`**. Якщо файлу ще немає — створи його з таким вмістом (канон js-run):
34
+
35
+ ```json title="jsconfig.json"
36
+ {
37
+ "compilerOptions": {
38
+ "lib": ["esnext"],
39
+ "module": "NodeNext",
40
+ "moduleResolution": "NodeNext",
41
+ "target": "esnext",
42
+ "checkJs": false
43
+ },
44
+ "include": ["src/**/*"]
45
+ }
46
+ ```
47
+
48
+ Якщо пакет не слідує структурі з `src/` (наприклад, лише `scripts/` у корені) — ця вимога не застосовується; для типових сервісів із `src/` файл обов’язковий і має збігатися з каноном.
49
+
31
50
  ## Використання @nitra/pino
32
51
 
33
52
  Проект використовує @nitra/pino для логування.
@@ -170,4 +189,4 @@ on:
170
189
 
171
190
  ## Перевірка
172
191
 
173
- `npx @nitra/cursor check js-run`
192
+ `npx @nitra/cursor check js-run` — зокрема для кожного backend workspace-пакета з каталогом **`src/`** перевіряє наявність **`jsconfig.json`** і збіг вмісту з каноном вище.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Оформлення репозиторію для npm модуля
3
3
  alwaysApply: true
4
- version: '1.9'
4
+ version: '1.10'
5
5
  ---
6
6
 
7
7
  Bun monorepo: workspace **`npm/`**, кореневий **`package.json`**, **`.github/workflows/`**; опційно **`demo/`**.
@@ -47,12 +47,18 @@ bunx -p typescript tsc src/**/*.js --declaration --allowJs --emitDeclarationOnly
47
47
 
48
48
  У робочій копії не повинно бути більше одного незбереженого в **git** підвищення **build**-версії за раз.
49
49
 
50
+ **Чеклист у тому ж наборі змін, що й правки під `npm/`:** `version` у **`npm/package.json`** → **+1**; зверху **`npm/CHANGELOG.md`** нова секція **`## [нова версія] - …`**; у секції лише те, що входить у цей реліз.
51
+
52
+ **Антипатерн:** не дописувати нові bullet-и в уже існуючу секцію **`## [X.Y.Z]`**, якщо паралельно не піднімаєш **`version`** до нового номера й не створюєш **нову** секцію зверху. Інакше змішуються різні релізи в одному номері, а `check npm-module` / `check changelog` гірше ловлять порушення.
53
+
50
54
  **Підказка:** щоб не дублювати bump і бачити різницю зі збереженим деревом, перевір `git status npm/package.json` або `git diff HEAD -- npm/package.json` перед другим підвищенням у тій самій гілці / наборі змін.
51
55
 
52
56
  ## CHANGELOG
53
57
 
54
58
  Окреме правило **`changelog`** ([changelog.mdc](changelog.mdc)) вимагає `npm/CHANGELOG.md` із записом для поточної версії (Keep a Changelog) і присутність `"CHANGELOG.md"` у масиві `files` у `npm/package.json`. Логіка — PR-scoped (сума по гілці vs `dev`).
55
59
 
60
+ Найновіша версія — **перша** секція **`## [version]`** у файлі (зверху після заголовка). Вона **має збігатися** з полем **`version`** у **`npm/package.json`** — це перевіряє **`npx @nitra/cursor check npm-module`**.
61
+
56
62
  ## npm publish
57
63
 
58
64
  **`npm-publish.yml`:** push у **`main`**, **`on.push.paths`** з **`npm/**`**, **`JS-DevTools/npm-publish@v4.1.5`**, **`with.package: npm/package.json`**, **`permissions.id-token: write`** (OIDC на npm).
@@ -96,4 +102,4 @@ jobs:
96
102
 
97
103
  ## Перевірка
98
104
 
99
- `npx @nitra/cursor check npm-module`
105
+ `npx @nitra/cursor check npm-module` — зокрема узгодженість першої секції **`npm/CHANGELOG.md`** з **`version`** у **`npm/package.json`** і нагадування про bump при незакомічених змінах під **`npm/`** (через `git`).
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.189",
3
+ "version": "1.8.192",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -24,9 +24,11 @@
24
24
  * `working-directory: <rootDir>` (див. `utils/depcheck-workflow.mjs`);
25
25
  * - «Паузи через setTimeout»: `new Promise(resolve => setTimeout(resolve, ms))` (з/без `await`)
26
26
  * треба замінити на `await setTimeout(ms)` з `node:timers/promises`
27
- * (див. `utils/promise-settimeout-scan.mjs`).
27
+ * (див. `utils/promise-settimeout-scan.mjs`);
28
+ * - «jsconfig.json»: у backend-пакеті з каталогом `src/` у корені має бути `jsconfig.json`,
29
+ * вміст якого збігається з каноном js-run.mdc (NodeNext і include на дерево `src`).
28
30
  */
29
- import { existsSync } from 'node:fs'
31
+ import { existsSync, statSync } from 'node:fs'
30
32
  import { readFile } from 'node:fs/promises'
31
33
  import { join, relative } from 'node:path'
32
34
 
@@ -52,6 +54,99 @@ import {
52
54
  import { walkDir } from './utils/walkDir.mjs'
53
55
  import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
54
56
 
57
+ /** Канонічний `jsconfig.json` для backend workspace-пакетів із каталогом `src/` (js-run.mdc). */
58
+ const CANONICAL_BACKEND_JSCONFIG = Object.freeze({
59
+ compilerOptions: Object.freeze({
60
+ lib: Object.freeze(['esnext']),
61
+ module: 'NodeNext',
62
+ moduleResolution: 'NodeNext',
63
+ target: 'esnext',
64
+ checkJs: false
65
+ }),
66
+ include: Object.freeze(['src/**/*'])
67
+ })
68
+
69
+ /**
70
+ * Глибока рівність для JSON-подібних значень (масиви — порядок важливий).
71
+ * @param {unknown} a
72
+ * @param {unknown} b
73
+ * @returns {boolean}
74
+ */
75
+ function deepEqualJson(a, b) {
76
+ if (a === b) return true
77
+ if (a === null || b === null || typeof a !== typeof b) return false
78
+ if (typeof a !== 'object') return false
79
+ if (Array.isArray(a) !== Array.isArray(b)) return false
80
+ if (Array.isArray(a)) {
81
+ if (a.length !== b.length) return false
82
+ for (const [i, v] of a.entries()) {
83
+ if (!deepEqualJson(v, b[i])) return false
84
+ }
85
+ return true
86
+ }
87
+ const ao = /** @type {Record<string, unknown>} */ (a)
88
+ const bo = /** @type {Record<string, unknown>} */ (b)
89
+ const keysA = Object.keys(ao).sort()
90
+ const keysB = Object.keys(bo).sort()
91
+ if (keysA.length !== keysB.length) return false
92
+ for (const [i, k] of keysA.entries()) {
93
+ if (k !== keysB[i]) return false
94
+ if (!deepEqualJson(ao[k], bo[k])) return false
95
+ }
96
+ return true
97
+ }
98
+
99
+ /**
100
+ * Чи існує непорожній за змістом маркер каталогу `src/` (рекомендована структура js-run).
101
+ * @param {string} absPackageRoot абсолютний корінь пакета
102
+ * @returns {boolean}
103
+ */
104
+ function backendPackageHasSrcDir(absPackageRoot) {
105
+ const srcPath = join(absPackageRoot, 'src')
106
+ try {
107
+ return statSync(srcPath).isDirectory()
108
+ } catch {
109
+ return false
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Перевіряє `jsconfig.json` для backend-пакетів із `src/`.
115
+ * @param {string} rootDir відносний шлях workspace
116
+ * @param {string} absPackageRoot абсолютний корінь пакета
117
+ * @param {string} label префікс `[pkg] `
118
+ * @param {(msg: string) => void} fail
119
+ * @param {(msg: string) => void} passFn
120
+ * @returns {Promise<void>}
121
+ */
122
+ async function checkBackendJsconfigWhenSrcPresent(rootDir, absPackageRoot, label, fail, passFn) {
123
+ if (!backendPackageHasSrcDir(absPackageRoot)) return
124
+
125
+ const jcPath = join(rootDir, 'jsconfig.json')
126
+ if (!existsSync(jcPath)) {
127
+ fail(
128
+ `${label}є каталог src/, але немає jsconfig.json — додай канонічний файл з js-run.mdc ` +
129
+ `(NodeNext, include: src/**/*).`
130
+ )
131
+ return
132
+ }
133
+ let parsed
134
+ try {
135
+ parsed = JSON.parse(await readFile(jcPath, 'utf8'))
136
+ } catch {
137
+ fail(`${label}jsconfig.json не вдалося розпарсити як JSON`)
138
+ return
139
+ }
140
+ if (!deepEqualJson(parsed, CANONICAL_BACKEND_JSCONFIG)) {
141
+ fail(
142
+ `${label}jsconfig.json не збігається з каноном js-run.mdc — заміни на шаблон з правила ` +
143
+ `(compilerOptions: lib esnext, module/moduleResolution NodeNext, target esnext, checkJs false; include: src/**/*).`
144
+ )
145
+ return
146
+ }
147
+ passFn(`${label}jsconfig.json узгоджено з js-run (пакет з src/)`)
148
+ }
149
+
55
150
  /**
56
151
  * Перетворює абсолютний шлях у posix-формі відносно кореня пакета.
57
152
  * @param {string} absPackageRoot абсолютний корінь пакета
@@ -216,6 +311,8 @@ async function checkWorkspacePackage(rootDir, ignorePaths, workflows, fail, pass
216
311
  return
217
312
  }
218
313
 
314
+ await checkBackendJsconfigWhenSrcPresent(rootDir, absPackageRoot, label, fail, passFn)
315
+
219
316
  const importViolations = await checkBunyanImports(absPackageRoot, ignorePaths, label, fail)
220
317
  if (importViolations === 0) {
221
318
  passFn(`${label}немає імпортів '@nitra/bunyan' / 'bunyan' у джерелах`)
@@ -10,10 +10,16 @@
10
10
  * файл під `./types/…`, у hk — `tsc -p tsconfig.emit-types.json`, у JSON-конфігу — потрібні compilerOptions для emit.
11
11
  *
12
12
  * Поля workflow перевіряються після **YAML parse**, щоб не плутати з коментарями.
13
+ *
14
+ * Версія та CHANGELOG: перший заголовок `## [version]` у `npm/CHANGELOG.md` має збігатися з `version` у
15
+ * `npm/package.json` (найсвіжіший реліз зверху). Якщо в git є незакомічені зміни під `npm/`, `version` у робочому
16
+ * файлі має відрізнятися від `HEAD` — інакше типовий пропуск bump після правок у пакеті.
13
17
  */
18
+ import { execFile } from 'node:child_process'
14
19
  import { existsSync } from 'node:fs'
15
20
  import { readFile, stat } from 'node:fs/promises'
16
21
  import { join } from 'node:path'
22
+ import { promisify } from 'node:util'
17
23
 
18
24
  import { createCheckReporter } from './utils/check-reporter.mjs'
19
25
  import {
@@ -26,8 +32,13 @@ import {
26
32
  import { loadCursorIgnorePaths } from './utils/load-cursor-config.mjs'
27
33
  import { walkDir } from './utils/walkDir.mjs'
28
34
 
35
+ const execFileAsync = promisify(execFile)
36
+
29
37
  const TYPES_FILE_RE = /^\.\/types\/.+\.d\.(ts|mts)$/
30
38
 
39
+ /** Перший заголовок релізу у Keep a Changelog (`## [1.2.3]`). */
40
+ const CHANGELOG_FIRST_VERSION_RE = /^## \[([^\]]+)\]/m
41
+
31
42
  /** Канонічний entrypoint типів для пакетів із вихідним `.js` під каталогом `npm/src` */
32
43
  const TYPES_INDEX = './types/index.d.ts'
33
44
 
@@ -233,6 +244,122 @@ async function checkEmitTypesConfig(passFn, failFn) {
233
244
  * @param {(msg: string) => void} passFn callback при успішній перевірці
234
245
  * @param {(msg: string) => void} failFn callback при помилці
235
246
  */
247
+ /**
248
+ * Чи виконано `git` у корені робочого дерева.
249
+ * @returns {Promise<boolean>}
250
+ */
251
+ async function gitInsideWorkTree() {
252
+ try {
253
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--is-inside-work-tree'], { encoding: 'utf8' })
254
+ return stdout.trim() === 'true'
255
+ } catch {
256
+ return false
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Список незакомічених шляхів під `npm/` відносно `HEAD`.
262
+ * @returns {Promise<string[] | null>} шляхи або `null`, якщо `git` недоступний
263
+ */
264
+ async function gitDiffNameOnlyNpm() {
265
+ try {
266
+ const { stdout } = await execFileAsync('git', ['diff', '--name-only', 'HEAD', '--', 'npm'], { encoding: 'utf8' })
267
+ return stdout.trim().split('\n').filter(Boolean)
268
+ } catch {
269
+ return null
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Поле `version` з `npm/package.json` на заданому git-ref (`HEAD:npm/package.json`).
275
+ * @param {string} refPath на кшталт `HEAD:npm/package.json`
276
+ * @returns {Promise<string | null>}
277
+ */
278
+ async function gitShowNpmPackageVersionAt(refPath) {
279
+ try {
280
+ const { stdout } = await execFileAsync('git', ['show', refPath], { encoding: 'utf8' })
281
+ const m = stdout.match(/"version":\s*"([^"]+)"/)
282
+ return m ? m[1] : null
283
+ } catch {
284
+ return null
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Версія з першого заголовка `## […]` у тексті CHANGELOG.
290
+ * @param {string} changelogText
291
+ * @returns {string | null}
292
+ */
293
+ function firstChangelogSectionVersion(changelogText) {
294
+ const m = changelogText.match(CHANGELOG_FIRST_VERSION_RE)
295
+ return m ? m[1] : null
296
+ }
297
+
298
+ /**
299
+ * Перший реліз у CHANGELOG має збігатися з `version` у `npm/package.json`.
300
+ * @param {(msg: string) => void} passFn
301
+ * @param {(msg: string) => void} failFn
302
+ * @returns {Promise<void>}
303
+ */
304
+ async function checkChangelogTopMatchesPackageVersion(passFn, failFn) {
305
+ if (!existsSync('npm/CHANGELOG.md') || !existsSync('npm/package.json')) return
306
+ const pkg = JSON.parse(await readFile('npm/package.json', 'utf8'))
307
+ const ver = typeof pkg.version === 'string' ? pkg.version : null
308
+ if (!ver) {
309
+ failFn('npm/package.json: відсутнє поле version')
310
+ return
311
+ }
312
+ const cl = await readFile('npm/CHANGELOG.md', 'utf8')
313
+ const first = firstChangelogSectionVersion(cl)
314
+ if (!first) {
315
+ failFn('npm/CHANGELOG.md: не знайдено жодного заголовка ## [version]')
316
+ return
317
+ }
318
+ if (first !== ver) {
319
+ failFn(
320
+ `npm/CHANGELOG.md: перша секція [${first}] не збігається з npm/package.json version "${ver}" ` +
321
+ '(зверху має бути найсвіжіший реліз і той самий номер — npm-module.mdc).'
322
+ )
323
+ return
324
+ }
325
+ passFn(`npm/CHANGELOG.md: перша секція [${first}] збігається з npm/package.json`)
326
+ }
327
+
328
+ /**
329
+ * Незакомічені зміни під `npm/` вимагають підвищення `version` відносно `HEAD`.
330
+ * @param {(msg: string) => void} passFn
331
+ * @param {(msg: string) => void} failFn
332
+ * @returns {Promise<void>}
333
+ */
334
+ async function checkDirtyNpmRequiresVersionBump(passFn, failFn) {
335
+ if (!(await gitInsideWorkTree())) {
336
+ passFn('npm-module: git недоступний або поза work tree — перевірку незакоміченого bump пропущено')
337
+ return
338
+ }
339
+ const changed = await gitDiffNameOnlyNpm()
340
+ if (changed === null) {
341
+ passFn('npm-module: git diff під npm/ недоступний — пропущено')
342
+ return
343
+ }
344
+ if (changed.length === 0) return
345
+
346
+ const headVer = await gitShowNpmPackageVersionAt('HEAD:npm/package.json')
347
+ if (headVer === null) return
348
+
349
+ const pkg = JSON.parse(await readFile('npm/package.json', 'utf8'))
350
+ const cur = typeof pkg.version === 'string' ? pkg.version : null
351
+ if (!cur) return
352
+
353
+ if (cur === headVer) {
354
+ failFn(
355
+ `Незакомічені зміни під npm/ (${changed.join(', ')}), але "version" у npm/package.json лишився ${cur} ` +
356
+ '(як у HEAD). Підвищ version (+1) і додай секцію ## [нова версія] зверху CHANGELOG (npm-module.mdc).'
357
+ )
358
+ return
359
+ }
360
+ passFn(`npm/: незакомічені зміни під npm/ узгоджені з підвищенням version (${headVer} → ${cur})`)
361
+ }
362
+
236
363
  async function checkPublishWorkflow(passFn, failFn) {
237
364
  const publishWf = '.github/workflows/npm-publish.yml'
238
365
  if (!existsSync(publishWf)) {
@@ -371,5 +498,8 @@ export async function check() {
371
498
 
372
499
  await checkPublishWorkflow(pass, fail)
373
500
 
501
+ await checkChangelogTopMatchesPackageVersion(pass, fail)
502
+ await checkDirtyNpmRequiresVersionBump(pass, fail)
503
+
374
504
  return reporter.getExitCode()
375
505
  }
@@ -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
+ }