@nitra/cursor 1.6.26 → 1.7.1

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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  ## Як це працює
6
6
 
7
- Репозиторій `@nitra/cursor` містить cursor-правила у директорії `mdc/`. CLI завантажує обрані правила з npm (через unpkg.com) і копіює їх у `.cursor/rules/` поточного проекту з префіксом `n-`.
7
+ Репозиторій `@nitra/cursor` містить cursor-правила у директорії `mdc/`. CLI копіює обрані правила з **каталогу `mdc/` того пакету, з якого виконується `bin/n-cursor.js`**: після `npm i` / `bun add` це зазвичай `node_modules/@nitra/cursor/mdc`; при **`npx @nitra/cursor`** пакет потрапляє в **кеш npx/npm**, і правила читаються з тієї розпакованої копії (у корені проєкту залежність не обов’язкова). Жодних окремих HTTP-запитів до CDN для файлів правил немає — лише те, що вже є в tarball пакету.
8
8
 
9
9
  Наприклад, правило `mdc/js-format.mdc` буде збережено як `.cursor/rules/n-js-format.mdc`.
10
10
 
@@ -28,16 +28,7 @@
28
28
  | `npm-module` | Структура репозиторію для npm-модуля (bun mono) |
29
29
  | `text` | Текстові файли: cspell, CI |
30
30
 
31
- Щоб завантажити правила конкретної версії пакету, додайте поле `version`:
32
-
33
- ```json
34
- {
35
- "$schema": "https://unpkg.com/@nitra/cursor/schemas/n-cursor.json",
36
- "version": "2.5.0",
37
- "rules": ["js-format", "text"],
38
- "skills": ["fix"]
39
- }
40
- ```
31
+ Щоб використовувати конкретну версію правил, оновіть залежність `@nitra/cursor` у проєкті (`bun add -d @nitra/cursor@<версія>` тощо). Поле `version` у `.n-cursor.json`, якщо воно лишилось у старих конфігах, **ігнорується**.
41
32
 
42
33
  ### v8r і власний каталог схем
43
34
 
@@ -57,7 +48,7 @@ CLI автоматично (команда завантаження правил
57
48
 
58
49
  1. Знайде або створить `.n-cursor.json` у поточній директорії (із полем `$schema` на JSON Schema пакету; якщо файл уже є без коректного `$schema`, поле буде додано або оновлено при зчитуванні конфігу)
59
50
  2. Створить директорію `.cursor/rules/`, якщо її ще немає
60
- 3. Завантажить кожне з перелічених у конфігу правило з unpkg.com і збереже файли з префіксом `n-`
51
+ 3. Скопіює кожне з перелічених у конфігу правило з `mdc/` установленого пакету і збереже файли з префіксом `n-`
61
52
  4. Після оновлення файлів на диску згенерує в корені проєкту **`AGENTS.md`**: повний вміст береться з шаблону пакету `AGENTS.template.md`, а список правил у шаблоні формується з **усіх наявних файлів `*.mdc`** у `.cursor/rules/` (відсортовано за ім’ям)
62
53
 
63
54
  ## Приклад виводу
package/bin/n-cursor.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * Якщо у корені репозиторію немає .n-cursor.json, спочатку перейменовується за наявності nitra-cursor.json;
13
13
  * у `.cursor/rules` файли `nitra-*.mdc` перейменовуються на `n-*.mdc`; інакше конфіг створюється автоматично
14
14
  * з усіма правилами з каталогу mdc пакету (їх можна відредагувати після створення). У файлі завжди має бути
15
- * поле `$schema` з посиланням на JSON Schema пакету; при зчитуванні конфігу воно додається або виправляється на диску, якщо відсутнє або некоректне.
15
+ * поле `$schema` з посиланням на JSON Schema пакету (публічний URL для IDE); при зчитуванні конфігу воно додається або виправляється на диску, якщо відсутнє або некоректне.
16
16
  *
17
17
  * Файл AGENTS.md у корені: щоразу повністю перезаписується змістом з AGENTS.template.md
18
18
  * пакету; список правил у шаблоні будується з файлів *.mdc у .cursor/rules поточного проєкту.
@@ -33,10 +33,9 @@ import { cwd } from 'node:process'
33
33
  import { fileURLToPath } from 'node:url'
34
34
 
35
35
  const PACKAGE_NAME = '@nitra/cursor'
36
- const UNPKG_BASE = 'https://unpkg.com'
37
36
  const CONFIG_FILE = '.n-cursor.json'
38
- /** URL JSON Schema для `.n-cursor.json` (поле `$schema` у файлі конфігурації) */
39
- const CONFIG_SCHEMA_URL = `${UNPKG_BASE}/${PACKAGE_NAME}/schemas/n-cursor.json`
37
+ /** Публічний URL JSON Schema для поля `$schema` у `.n-cursor.json` (IDE); вміст правил CLI читає лише з диска пакету */
38
+ const CONFIG_SCHEMA_URL = 'https://unpkg.com/@nitra/cursor/schemas/n-cursor.json'
40
39
  const AGENTS_FILE = 'AGENTS.md'
41
40
  const AGENTS_TEMPLATE_FILE = 'AGENTS.template.md'
42
41
  const RULES_DIR = '.cursor/rules'
@@ -87,19 +86,6 @@ async function discoverBundledSkillNames() {
87
86
  .toSorted((a, b) => a.localeCompare(b))
88
87
  }
89
88
 
90
- /**
91
- * Завантажує текст з URL
92
- * @param {string} url адреса HTTP(S)
93
- * @returns {Promise<string>} тіло відповіді як UTF-8 текст
94
- */
95
- async function fetchText(url) {
96
- const response = await fetch(url)
97
- if (!response.ok) {
98
- throw new Error(`HTTP ${response.status} — не вдалося завантажити: ${url}`)
99
- }
100
- return response.text()
101
- }
102
-
103
89
  /**
104
90
  * Перейменовує у каталозі правил файли `nitra-*.mdc` → `n-*.mdc`. Якщо `n-*.mdc` уже є, застарілий файл видаляється.
105
91
  * @param {string} rulesDir абсолютний шлях до `.cursor/rules`
@@ -148,7 +134,7 @@ async function migrateLegacyConfigIfNeeded() {
148
134
 
149
135
  /**
150
136
  * Зчитує конфіг .n-cursor.json з поточної директорії
151
- * @returns {Promise<{ $schema: string, rules: string[], skills: string[], version?: string } & Record<string, unknown>>} rules, skills (id без префікса n-), опційно version; при відсутності файлу створює дефолтний конфіг
137
+ * @returns {Promise<{ $schema: string, rules: string[], skills: string[], version?: string } & Record<string, unknown>>} rules, skills (id без префікса n-); поле version у файлі за наявності ігнорується при синхронізації правил
152
138
  */
153
139
  async function readConfig() {
154
140
  await migrateLegacyConfigIfNeeded()
@@ -191,18 +177,6 @@ async function readConfig() {
191
177
  return config
192
178
  }
193
179
 
194
- /**
195
- * Повертає URL для завантаження правила з unpkg
196
- * @param {string} ruleName - ім'я без розширення, наприклад "js-format"
197
- * @param {string} [version] - версія пакету (необов'язково, за замовчуванням "latest")
198
- * @returns {string} повний URL файлу правила на unpkg
199
- */
200
- function buildUrl(ruleName, version) {
201
- const name = ruleName.endsWith('.mdc') ? ruleName : `${ruleName}.mdc`
202
- const ver = version ? `@${version}` : '@latest'
203
- return `${UNPKG_BASE}/${PACKAGE_NAME}${ver}/mdc/${name}`
204
- }
205
-
206
180
  /**
207
181
  * Витягує чисте ім'я файлу правила (без шляху, але зберігає .mdc)
208
182
  * "npm/mdc/js-format.mdc" → "js-format.mdc"
@@ -215,6 +189,40 @@ function normalizeRuleName(ruleName) {
215
189
  return basename(name)
216
190
  }
217
191
 
192
+ /**
193
+ * Читає вміст правила з каталогу `mdc/` установленого пакету (наприклад `node_modules/@nitra/cursor/mdc` або кеш npx).
194
+ * @param {string} rule елемент масиву rules з `.n-cursor.json`
195
+ * @returns {Promise<string>} текст правила для запису в `.cursor/rules/n-*.mdc`
196
+ */
197
+ function readBundledRuleContent(rule) {
198
+ const bundledName = normalizeRuleName(rule)
199
+ const bundledPath = join(BUNDLED_MDC_DIR, bundledName)
200
+ if (!existsSync(bundledPath)) {
201
+ throw new Error(
202
+ `Немає файлу ${bundledName} у ${BUNDLED_MDC_DIR}. Оновіть ${PACKAGE_NAME} або приберіть "${rule}" з rules у ${CONFIG_FILE}.`
203
+ )
204
+ }
205
+ return readFile(bundledPath, 'utf8')
206
+ }
207
+
208
+ /**
209
+ * Версія з `package.json` установленого пакету (каталог поруч із `bin/`).
210
+ * @returns {Promise<string | null>} поле `version` рядком або `null`, якщо файлу немає / помилка парсингу
211
+ */
212
+ async function readBundledPackageVersion() {
213
+ const pkgPath = join(binDir, '..', 'package.json')
214
+ if (!existsSync(pkgPath)) {
215
+ return null
216
+ }
217
+ try {
218
+ const raw = await readFile(pkgPath, 'utf8')
219
+ const pkg = JSON.parse(raw)
220
+ return typeof pkg.version === 'string' ? pkg.version : null
221
+ } catch {
222
+ return null
223
+ }
224
+ }
225
+
218
226
  /**
219
227
  * Нормалізує id skill з конфігу до форми без префікса n- (як «fix»)
220
228
  * @param {string} skillName елемент масиву skills або ім'я каталогу
@@ -618,7 +626,7 @@ async function runChecks(requestedRules) {
618
626
  }
619
627
 
620
628
  /**
621
- * Завантажує правила з npm та синхронізує локальні файли
629
+ * Копіює правила з каталогу `mdc/` установленого пакету та синхронізує `.cursor/rules`
622
630
  * @returns {Promise<void>}
623
631
  */
624
632
  async function runSync() {
@@ -633,8 +641,12 @@ async function runSync() {
633
641
  }
634
642
 
635
643
  const { rules, skills, version } = config
644
+ const bundledVer = await readBundledPackageVersion()
645
+ if (bundledVer) {
646
+ console.log(`📦 Джерело правил: ${PACKAGE_NAME}@${bundledVer}`)
647
+ }
636
648
  if (version) {
637
- console.log(`📦 Версія пакету: ${version}`)
649
+ console.log(`⚠️ Поле "version" у ${CONFIG_FILE} ігнорується; правила беруться з установленого пакету.\n`)
638
650
  }
639
651
  console.log(`📋 Правил до завантаження: ${rules.length}`)
640
652
  console.log(`📋 Skills до синхронізації: ${skills.length}`)
@@ -646,13 +658,12 @@ async function runSync() {
646
658
  let failCount = 0
647
659
 
648
660
  for (const rule of rules) {
649
- const url = buildUrl(rule, version)
650
661
  const fileName = `${RULE_PREFIX}${normalizeRuleName(rule)}`
651
662
  const destPath = join(rulesDir, fileName)
652
663
 
653
664
  try {
654
665
  process.stdout.write(` ⬇ ${rule} → ${RULES_DIR}/${fileName} ... `)
655
- const content = await fetchText(url)
666
+ const content = await readBundledRuleContent(rule)
656
667
  await writeFile(destPath, content, 'utf8')
657
668
  console.log(`✅`)
658
669
  successCount++
package/mdc/bun.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Bun як єдиний package manager у монорепо
3
3
  alwaysApply: true
4
- version: '1.2'
4
+ version: '1.3'
5
5
  ---
6
6
 
7
7
  Проект використовує тільки Bun для керування залежностями та запуску скриптів.
@@ -87,3 +87,5 @@ FROM oven/bun:alpine AS build-env
87
87
  Якщо в кореневому @package.json існують скрипти з префіксом `lint-`, обов'язково створюй `lint` скрипт, який буде запускати всі ці скрипти з лінт-префіксом.
88
88
 
89
89
  У кінці скрипта `lint` додай `&& oxfmt .`.
90
+
91
+ Якщо в **`.n-cursor.json`** у масиві **`rules`** є **`docker`**, у кореневому `package.json` **обов'язково** скрипт **`lint-docker`** (див. **`docker.mdc`**) і рядок **`bun run lint-docker`** у **`lint`**. Якщо є **`k8s`** — **обов'язково** **`lint-k8s`** і **`bun run lint-k8s`** у **`lint`** (див. **`k8s.mdc`**). Перевірка — **`npx @nitra/cursor check bun`**.
package/mdc/docker.mdc CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: Dockerfile — lint-docker / hadolint; перевірка check-docker
3
- version: '1.5'
3
+ version: '1.6'
4
4
  globs: "**/Dockerfile*"
5
5
  alwaysApply: false
6
6
  ---
@@ -30,6 +30,8 @@ CLI **`hadolint`** приймає лише **явні шляхи** (`[DOCKERFILE
30
30
  }
31
31
  ```
32
32
 
33
+ Якщо правило **`docker`** підключено в **`.n-cursor.json`** (масив **`rules`**), у **кореневому** `package.json` **обов'язково** мають бути скрипт **`lint-docker`** і виклик **`bun run lint-docker`** у агрегованому **`lint`** (див. **`bun.mdc`**). Це перевіряє **`npx @nitra/cursor check bun`**.
34
+
33
35
  Додай workflow **`.github/workflows/lint-docker.yml`** (гілка **`dev`**, лише **`.yml`**, узгоджено з **`ga.mdc`**):
34
36
 
35
37
  ```yaml title=".github/workflows/lint-docker.yml"
@@ -95,7 +97,7 @@ jobs:
95
97
 
96
98
  Узгоджуй версію hadolint **v2.12.0** з **`HADOLINT_IMAGE`** у **`npm/scripts/utils/docker-hadolint.mjs`**.
97
99
 
98
- Кореневий скрипт **`lint`** (див. **`n-bun.mdc`**) має викликати **`lint-docker`**, якщо він існує.
100
+ Кореневий скрипт **`lint`** (див. **`n-bun.mdc`**) **обов'язково** містить **`bun run lint-docker`**, коли в проєкті підключено правило **`docker`**.
99
101
 
100
102
  ## Запуск
101
103
 
@@ -119,6 +121,6 @@ jobs:
119
121
 
120
122
  `npx @nitra/cursor check docker`
121
123
 
122
- Після змін у Dockerfile: **`bun run lint-docker`**, якщо скрипт додано в проєкт.
124
+ Після змін у Dockerfile: **`bun run lint-docker`** (обов'язково для проєктів з правилом **`docker`** у **`.n-cursor.json`**).
123
125
 
124
126
  Kubernetes YAML і `$schema` у `k8s/` — окреме правило **`k8s.mdc`**, **`check k8s`**.
package/mdc/js-lint.mdc CHANGED
@@ -140,6 +140,10 @@ export default [
140
140
  }
141
141
  ```
142
142
 
143
+ ## Тести
144
+
145
+ Проекту повинен бути покритий unit тестами за допомогою Bun test.
146
+
143
147
  **Код:** синтаксис Node **24+**, **top level await**.
144
148
 
145
149
  ## Перевірка
package/mdc/k8s.mdc CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: K8s YAML — $schema (yaml-language-server); lint-k8s (kubeconform, kubescape); check-k8s
3
- version: '1.7'
3
+ version: '1.8'
4
4
  globs: "**/k8s/**/*.{yaml,yml}"
5
5
  alwaysApply: false
6
6
  ---
@@ -42,6 +42,8 @@ alwaysApply: false
42
42
  }
43
43
  ```
44
44
 
45
+ Якщо правило **`k8s`** підключено в **`.n-cursor.json`** (масив **`rules`**), у **кореневому** `package.json` **обов'язково** мають бути скрипт **`lint-k8s`** і виклик **`bun run lint-k8s`** у агрегованому **`lint`** (див. **`bun.mdc`**). Це перевіряє **`npx @nitra/cursor check bun`**.
46
+
45
47
  Шлях до скрипта підстав свій (`./scripts/…` після копіювання, `node_modules/@nitra/cursor/scripts/…` якщо пакет у залежностях).
46
48
 
47
49
  Додай workflow **`.github/workflows/lint-k8s.yml`** (гілка **`dev`**, лише **`.yml`**, узгоджено з **`ga.mdc`**):
@@ -105,17 +107,17 @@ jobs:
105
107
 
106
108
  Після **`install.sh`** kubescape може опинитися поза стандартним PATH; за потреби додай крок **`echo "$HOME/.kubescape/bin" >> $GITHUB_PATH`** (див. документацію [kubescape](https://github.com/kubescape/kubescape#readme)).
107
109
 
108
- Кореневий скрипт **`lint`** (див. **`n-bun.mdc`**) має викликати **`lint-k8s`**, якщо він існує.
110
+ Кореневий скрипт **`lint`** (див. **`n-bun.mdc`**) **обов'язково** містить **`bun run lint-k8s`**, коли в проєкті підключено правило **`k8s`**.
109
111
 
110
112
  ## Перевірка
111
113
 
112
114
  **`npx @nitra/cursor check k8s`** — узгодженість першого рядка (`$schema`), один рядок `# yaml-language-server` на файл, правило для `.yml`, відповідність URL першому YAML-документу (до `---`) за логікою нижче. Якщо під `k8s` немає yaml/yml — перевірку пропущено. Синтаксис YAML і зміст маніфесту скрипт не перевіряє — вручну.
113
115
 
114
- Після змін у маніфестах: **`bun run lint-k8s`** (kubeconform + kubescape), якщо скрипт додано в проєкт.
116
+ Після змін у маніфестах: **`bun run lint-k8s`** (kubeconform + kubescape; обов'язково для проєктів з правилом **`k8s`** у **`.n-cursor.json`**).
115
117
 
116
118
  ## Що закодовано в `check-k8s.mjs`
117
119
 
118
- При зміні правил синхронно оновлюй **`YANNH_PIN`**, **`YANNH_GROUPS`**, а в **`run-k8s.mjs`** — константу **`KUBERNETES_VERSION`** (число з PIN, наприклад `v1.33.9-standalone-strict` → **`1.33.9`**).
120
+ При зміні правил синхронно оновлюй **`YANNH_PIN`**, **`YANNH_REF`** (якщо зміниться гілка за замовчуванням у репо yannh), **`YANNH_GROUPS`**, а в **`run-k8s.mjs`** — константу **`KUBERNETES_VERSION`** (число з PIN, наприклад `v1.33.9-standalone-strict` → **`1.33.9`**).
119
121
 
120
122
  - Обхід з пропуском `node_modules`, `.git`, `dist`, `coverage`, `.turbo`, `.next`.
121
123
  - **`file:`** у `$schema` — URL до apiVersion/kind не звіряється; **`https:`** — kustomization за іменем файлу → Schema Store; `v1` → yannh; група з `YANNH_GROUPS` → yannh; інакше → datree.
@@ -130,11 +132,11 @@ jobs:
130
132
  Орієнтир — **перший документ** (до наступного `---`).
131
133
 
132
134
  1. **Ім’я** `kustomization.yaml` або `kustomization.yml` → `https://json.schemastore.org/kustomization.json`.
133
- 2. **`apiVersion: v1`** → yannh, PIN **`v1.33.9-standalone-strict`**:
134
- `https://raw.githubusercontent.com/yannh/kubernetes-json-schema/<PIN>/<kind>-v1.json`
135
+ 2. **`apiVersion: v1`** → yannh, PIN набору схем **`v1.33.9-standalone-strict`**, ref репозиторію для raw URL — **`master`** (каталог версії не є коренем репо на GitHub):
136
+ `https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/<PIN>/<kind>-v1.json`
135
137
  `<kind>`: літери в нижньому регістрі без роздільників між CamelCase (наприклад `Service` → `service`).
136
138
  3. **`apiVersion: group/version`** і **group** у **`YANNH_GROUPS`** у скрипті → yannh:
137
- `https://raw.githubusercontent.com/yannh/kubernetes-json-schema/<PIN>/<kind>-<group-з-крапками-як-дефіси>-<version>.json`
139
+ `https://raw.githubusercontent.com/yannh/kubernetes-json-schema/master/<PIN>/<kind>-<group-з-крапками-як-дефіси>-<version>.json`
138
140
  Приклади: `apps/v1` + `Deployment` → `deployment-apps-v1.json`; `networking.k8s.io/v1` + `Ingress` → `ingress-networking-k8s-io-v1.json`.
139
141
  4. **Інакше** (CRD тощо) → [datreeio/CRDs-catalog](https://github.com/datreeio/CRDs-catalog):
140
142
  `https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/CRDs/<group>/<kind>_<version>.json`
package/mdc/text.mdc CHANGED
@@ -201,6 +201,8 @@ jobs:
201
201
 
202
202
  Підлаштуй `language` під проєкт (наприклад додай `ru-ru`, якщо потрібна перевірка російською). Порядок у `import` може впливати на пріоритет словників — тримай корпоративний `@nitra/cspell-dict` там, де зручно для ваших правил.
203
203
 
204
+ **Український апостроф:** у словах не використовуй прямий символ `'` (U+0027); потрібен типографський апостроф `’` (U+2019). Якщо після цього cspell досі підсвічує слово як невідоме — додай його до масиву `words` у `.cspell.json`.
205
+
204
206
  ## Локальні виключення (cspell `words`)
205
207
 
206
208
  Коли **cspell** підсвічує слово, спочатку **виправ текст**, а не розширюй словник:
package/mdc/vue.mdc CHANGED
@@ -207,6 +207,10 @@ export default defineConfig({
207
207
  })
208
208
  ```
209
209
 
210
+ ## Тести
211
+
212
+ Проекту повинен бути покритий тестами E2E за допомогою Playwright.
213
+
210
214
  ### Висновок
211
215
 
212
216
  Дотримуючись цих практик і правил, можна будувати масштабовані, підтримувані та ефективні застосунки на Vue 3 з Composition API. Завжди звіряйся з офіційною документацією Vue 3 щодо оновлень і нових можливостей.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.6.26",
3
+ "version": "1.7.1",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -32,7 +32,7 @@
32
32
  ],
33
33
  "type": "module",
34
34
  "scripts": {
35
- "test": "npx coverage-node test/index.js"
35
+ "test": "bun test tests"
36
36
  },
37
37
  "engines": {
38
38
  "node": ">=24"
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "version": {
31
31
  "type": "string",
32
- "description": "Версія пакету @nitra/cursor на unpkg для завантаження правил (необов'язково, інакше latest)."
32
+ "description": "Застаріле поле, ігнорується CLI. Правила завжди копіюються з каталогу mdc/ установленого пакету (node_modules або кеш npx); змініть версію через оновлення залежності."
33
33
  }
34
34
  },
35
35
  "required": ["rules"]
@@ -4,6 +4,9 @@
4
4
  * Очікує наявність `bun.lock`, забороняє lockfile та артефакти yarn/pnpm, директорію `.yarn`
5
5
  * і поле `packageManager` у кореневому `package.json`.
6
6
  *
7
+ * Якщо в `.n-cursor.json` у `rules` є `docker` або `k8s`, вимагає у кореневому `package.json`
8
+ * відповідно скриптів `lint-docker` / `lint-k8s` (див. docker.mdc, k8s.mdc).
9
+ *
7
10
  * Якщо в кореневому `package.json` є скрипти з префіксом `lint-`, перевіряє наявність агрегованого
8
11
  * скрипта `lint`, у якому через `bun run <ім’я>` викликаються всі такі скрипти, і що рядок `lint`
9
12
  * закінчується на `&& oxfmt .`.
@@ -13,6 +16,26 @@ import { readFile } from 'node:fs/promises'
13
16
 
14
17
  import { pass } from './utils/pass.mjs'
15
18
 
19
+ /**
20
+ * Зчитує ідентифікатори правил з `.n-cursor.json` (поле `rules`).
21
+ * @returns {Promise<Set<string>>} множина рядків id правил або порожня, якщо файлу/поля немає
22
+ */
23
+ async function loadNCursorRules() {
24
+ if (!existsSync('.n-cursor.json')) {
25
+ return new Set()
26
+ }
27
+ try {
28
+ const raw = JSON.parse(await readFile('.n-cursor.json', 'utf8'))
29
+ const list = raw?.rules
30
+ if (!Array.isArray(list)) {
31
+ return new Set()
32
+ }
33
+ return new Set(list.map(String))
34
+ } catch {
35
+ return new Set()
36
+ }
37
+ }
38
+
16
39
  /**
17
40
  * Перевіряє відповідність проєкту правилам bun.mdc
18
41
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
@@ -44,6 +67,8 @@ export async function check() {
44
67
  fail('Відсутній bun.lock — запусти bun i')
45
68
  }
46
69
 
70
+ const cursorRules = await loadNCursorRules()
71
+
47
72
  if (existsSync('package.json')) {
48
73
  const pkg = JSON.parse(await readFile('package.json', 'utf8'))
49
74
  if (pkg.packageManager) {
@@ -53,6 +78,24 @@ export async function check() {
53
78
  }
54
79
 
55
80
  const scripts = pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {}
81
+
82
+ if (cursorRules.has('docker')) {
83
+ if (scripts['lint-docker']) {
84
+ pass('package.json: є `lint-docker` (правило docker у .n-cursor.json)')
85
+ } else {
86
+ fail(
87
+ 'У .n-cursor.json є правило `docker` — додай скрипт `lint-docker` у кореневий package.json (див. docker.mdc)'
88
+ )
89
+ }
90
+ }
91
+
92
+ if (cursorRules.has('k8s')) {
93
+ if (scripts['lint-k8s']) {
94
+ pass('package.json: є `lint-k8s` (правило k8s у .n-cursor.json)')
95
+ } else {
96
+ fail('У .n-cursor.json є правило `k8s` — додай скрипт `lint-k8s` у кореневий package.json (див. k8s.mdc)')
97
+ }
98
+ }
56
99
  const lintPrefixed = Object.keys(scripts).filter(name => name.startsWith('lint-'))
57
100
  if (lintPrefixed.length > 0) {
58
101
  const aggregate = typeof scripts.lint === 'string' ? scripts.lint : ''
@@ -16,7 +16,7 @@ import { walkDir } from './utils/walkDir.mjs'
16
16
  * @param {string} name basename шляху
17
17
  * @returns {boolean} true для Dockerfile / Dockerfile.* / Containerfile / Containerfile.*
18
18
  */
19
- function isDockerfileName(name) {
19
+ export function isDockerfileName(name) {
20
20
  const n = name.toLowerCase()
21
21
  if (n === 'dockerfile' || n === 'containerfile') return true
22
22
  if (n.startsWith('dockerfile.') || n.startsWith('containerfile.')) return true
@@ -28,7 +28,7 @@ function isDockerfileName(name) {
28
28
  * @param {string} root корінь репозиторію
29
29
  * @returns {Promise<string[]>} відсортовані абсолютні шляхи
30
30
  */
31
- async function findDockerfilePaths(root) {
31
+ export async function findDockerfilePaths(root) {
32
32
  /** @type {string[]} */
33
33
  const out = []
34
34
  await walkDir(root, p => {
@@ -14,9 +14,12 @@ import { walkDir } from './utils/walkDir.mjs'
14
14
  /** Версія набору схем yannh — узгоджено з k8s.mdc */
15
15
  const YANNH_PIN = 'v1.33.9-standalone-strict'
16
16
 
17
+ /** Гілка репозиторію yannh/kubernetes-json-schema для raw.githubusercontent.com (каталог набору в URL одразу після ref). */
18
+ const YANNH_REF = 'master'
19
+
17
20
  const KUSTOMIZATION_SCHEMA = 'https://json.schemastore.org/kustomization.json'
18
21
 
19
- const YANNH_BASE = `https://raw.githubusercontent.com/yannh/kubernetes-json-schema/${YANNH_PIN}/`
22
+ const YANNH_BASE = `https://raw.githubusercontent.com/yannh/kubernetes-json-schema/${YANNH_REF}/${YANNH_PIN}/`
20
23
 
21
24
  const DATREE_CRD_BASE = 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/CRDs/'
22
25
 
@@ -56,7 +59,7 @@ const MODELINE_RE = /^#\s*yaml-language-server:\s*\$schema=(\S+)\s*$/
56
59
  * @param {string} filePath шлях до файлу
57
60
  * @returns {boolean} true, якщо серед компонентів шляху є каталог `k8s`
58
61
  */
59
- function pathHasK8sSegment(filePath) {
62
+ export function pathHasK8sSegment(filePath) {
60
63
  const parts = filePath.split(/[/\\]/u)
61
64
  return parts.includes('k8s')
62
65
  }
@@ -137,7 +140,7 @@ function kindToSchemaFilePart(kind) {
137
140
  * @param {string} doc перший YAML-документ після modeline
138
141
  * @returns {{ expected: string | null, reason: string }} reason — для повідомлень про помилку
139
142
  */
140
- function expectedSchemaUrl(filePath, doc) {
143
+ export function expectedSchemaUrl(filePath, doc) {
141
144
  const base = basename(filePath)
142
145
  const baseLower = base.toLowerCase()
143
146
 
@@ -4,12 +4,42 @@
4
4
  * cspell, markdownlint-cli2, скрипт `lint-text` з v8r (`run-v8r.mjs` або чотири `bunx v8r`),
5
5
  * `.v8rignore` (vscode JSON),
6
6
  * workflow `lint-text.yml`, розширення VSCode для markdownlint.
7
+ *
8
+ * Якщо є `.cursor/rules/n-text.mdc` і/або `npm/mdc/text.mdc` — перевіряє наявність абзацу про український
9
+ * апостроф (U+0027 vs U+2019) і приклад з символом U+2019 у тексті.
7
10
  */
8
11
  import { existsSync } from 'node:fs'
9
12
  import { readFile } from 'node:fs/promises'
10
13
 
11
14
  import { pass } from './utils/pass.mjs'
12
15
 
16
+ /** Заголовок абзацу про апостроф у text.mdc / n-text.mdc. */
17
+ const UK_APOSTROPHE_HEADING = '**Український апостроф:**'
18
+
19
+ /**
20
+ * Перевіряє абзац про український апостроф у вмісті правила text.
21
+ * @param {string} filePath шлях до файлу (для повідомлень)
22
+ * @param {string} body вміст .mdc у UTF-8
23
+ * @param {(msg: string) => void} failFn реєструє порушення (exit 1)
24
+ * @param {(msg: string) => void} passFn реєструє успішну перевірку
25
+ * @returns {void}
26
+ */
27
+ function verifyUkApostropheRuleParagraph(filePath, body, failFn, passFn) {
28
+ if (!body.includes(UK_APOSTROPHE_HEADING)) {
29
+ failFn(`${filePath}: додай абзац **Український апостроф:** (U+0027 / U+2019, масив words) — див. text.mdc`)
30
+ return
31
+ }
32
+ if (!body.includes('U+0027') || !body.includes('U+2019')) {
33
+ failFn(`${filePath}: абзац про апостроф має містити позначки U+0027 та U+2019`)
34
+ return
35
+ }
36
+ if (!body.includes('\u2019')) {
37
+ failFn(`${filePath}: у прикладі має бути типографський символ U+2019 (\u2019)`)
38
+ return
39
+ }
40
+ passFn(`${filePath}: абзац про український апостроф на місці`)
41
+ }
42
+
13
43
  /**
14
44
  * Перевіряє відповідність проєкту правилам text.mdc (cspell, markdownlint-cli2, v8r)
15
45
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
@@ -104,6 +134,16 @@ export async function check() {
104
134
  fail('.cspell.json не існує — створи його')
105
135
  }
106
136
 
137
+ const textRulePaths = ['.cursor/rules/n-text.mdc', 'npm/mdc/text.mdc'].filter(p => existsSync(p))
138
+ if (textRulePaths.length === 0) {
139
+ pass('n-text.mdc / npm/mdc/text.mdc відсутні — перевірку абзацу про апостроф пропущено')
140
+ } else {
141
+ for (const p of textRulePaths) {
142
+ const body = await readFile(p, 'utf8')
143
+ verifyUkApostropheRuleParagraph(p, body, fail, pass)
144
+ }
145
+ }
146
+
107
147
  if (existsSync('package.json')) {
108
148
  const pkg = JSON.parse(await readFile('package.json', 'utf8'))
109
149
  const devDeps = pkg.devDependencies || {}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Визначення, чи виконується поточний ESM-модуль як точка входу CLI, а не як import у тестах чи інших модулях.
3
+ *
4
+ * У Bun використовується `import.meta.main`; у Node — порівняння `import.meta.url` з `process.argv[1]`
5
+ * після `resolve`, щоб `bun path/to/script.mjs` і `node path/to/script.mjs` коректно вважалися прямим запуском.
6
+ */
7
+ import { resolve } from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ /**
11
+ * Чи виконується модуль як точка входу CLI (прямий запуск), а не через import.
12
+ * @returns {boolean} `true`, якщо файл запущено напряму; інакше `false`.
13
+ */
14
+ export function isRunAsCli() {
15
+ if (import.meta.main === true) {
16
+ return true
17
+ }
18
+ try {
19
+ const entry = process.argv[1]
20
+ if (!entry) {
21
+ return false
22
+ }
23
+ return fileURLToPath(import.meta.url) === resolve(entry)
24
+ } catch {
25
+ return false
26
+ }
27
+ }
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { basename } from 'node:path'
11
11
 
12
+ import { isRunAsCli } from './cli-entry.mjs'
12
13
  import { lintDockerfileWithHadolint, posixRel } from './utils/docker-hadolint.mjs'
13
14
  import { pass } from './utils/pass.mjs'
14
15
  import { walkDir } from './utils/walkDir.mjs'
@@ -18,7 +19,7 @@ import { walkDir } from './utils/walkDir.mjs'
18
19
  * @param {string} name basename шляху
19
20
  * @returns {boolean} true, якщо ім’я підходить під lint-docker
20
21
  */
21
- function isLintDockerfileName(name) {
22
+ export function isLintDockerfileName(name) {
22
23
  const n = name.toLowerCase()
23
24
  if (n === 'dockerfile') return true
24
25
  return n.endsWith('.dockerfile') && n.length > '.dockerfile'.length
@@ -29,7 +30,7 @@ function isLintDockerfileName(name) {
29
30
  * @param {string} root корінь репозиторію
30
31
  * @returns {Promise<string[]>} відсортовані абсолютні шляхи
31
32
  */
32
- async function findLintDockerfilePaths(root) {
33
+ export async function findLintDockerfilePaths(root) {
33
34
  /** @type {string[]} */
34
35
  const out = []
35
36
  await walkDir(root, p => {
@@ -73,4 +74,6 @@ async function main() {
73
74
  return exitCode
74
75
  }
75
76
 
76
- process.exitCode = await main()
77
+ if (isRunAsCli()) {
78
+ process.exitCode = await main()
79
+ }
@@ -15,6 +15,7 @@
15
15
  import { spawnSync } from 'node:child_process'
16
16
  import { basename, dirname } from 'node:path'
17
17
 
18
+ import { isRunAsCli } from './cli-entry.mjs'
18
19
  import { walkDir } from './utils/walkDir.mjs'
19
20
 
20
21
  /** Версія Kubernetes для kubeconform — синхронно з YANNH_PIN (без префікса v і суфікса -standalone-strict). */
@@ -29,7 +30,7 @@ const DATREE_CRD_SCHEMA_LOCATION =
29
30
  * @param {string} filePath шлях до файлу
30
31
  * @returns {boolean} true, якщо серед компонентів шляху є каталог `k8s`
31
32
  */
32
- function pathHasK8sSegment(filePath) {
33
+ export function pathHasK8sSegment(filePath) {
33
34
  const parts = filePath.split(/[/\\]/u)
34
35
  return parts.includes('k8s')
35
36
  }
@@ -39,7 +40,7 @@ function pathHasK8sSegment(filePath) {
39
40
  * @param {string} absFile абсолютний шлях до yaml
40
41
  * @returns {string | null} абсолютний шлях до `…/k8s` або null, якщо сегмента `k8s` у ланцюжку немає
41
42
  */
42
- function k8sRootFromFile(absFile) {
43
+ export function k8sRootFromFile(absFile) {
43
44
  let dir = dirname(absFile)
44
45
  for (let i = 0; i < 64; i++) {
45
46
  if (basename(dir) === 'k8s') return dir
@@ -55,7 +56,7 @@ function k8sRootFromFile(absFile) {
55
56
  * @param {string} root корінь репозиторію
56
57
  * @returns {Promise<string[]>} відсортовані абсолютні шляхи до каталогів `k8s`
57
58
  */
58
- async function findK8sRoots(root) {
59
+ export async function findK8sRoots(root) {
59
60
  /** @type {Set<string>} */
60
61
  const roots = new Set()
61
62
  await walkDir(root, p => {
@@ -136,4 +137,6 @@ async function main() {
136
137
  return ks
137
138
  }
138
139
 
139
- process.exitCode = await main()
140
+ if (isRunAsCli()) {
141
+ process.exitCode = await main()
142
+ }
@@ -18,17 +18,36 @@ import { existsSync } from 'node:fs'
18
18
  import { dirname, join } from 'node:path'
19
19
  import { fileURLToPath } from 'node:url'
20
20
 
21
+ import { isRunAsCli } from './cli-entry.mjs'
22
+
21
23
  /** Типові glob-и для форматів, які обробляє v8r (див. опис CLI v8r). */
22
- const DEFAULT_GLOBS = ['**/*.json', '**/*.json5', '**/*.yml', '**/*.yaml', '**/*.toml']
24
+ export const DEFAULT_V8R_GLOBS = ['**/*.json', '**/*.json5', '**/*.yml', '**/*.yaml', '**/*.toml']
23
25
 
24
26
  /** Абсолютний шлях до `schemas/v8r-catalog.json` поруч з цим скриптом у пакеті `@nitra/cursor`. */
25
- const V8R_CATALOG_PATH = join(dirname(fileURLToPath(import.meta.url)), '../schemas/v8r-catalog.json')
27
+ export const V8R_CATALOG_PATH = join(dirname(fileURLToPath(import.meta.url)), '../schemas/v8r-catalog.json')
28
+
29
+ /**
30
+ * Повертає шлях до каталогу схем v8r для пакета (для тестів і діагностики).
31
+ * @returns {string} абсолютний шлях до v8r-catalog.json
32
+ */
33
+ export function getV8rCatalogPath() {
34
+ return V8R_CATALOG_PATH
35
+ }
26
36
 
27
- if (existsSync(V8R_CATALOG_PATH)) {
28
- const globs = process.argv.length > 2 ? process.argv.slice(2) : DEFAULT_GLOBS
37
+ /**
38
+ * Запускає послідовні виклики v8r по glob-ам; не змінює process.exitCode (лише повертає код).
39
+ * @param {string[]} [globs] патерни; за замовчуванням DEFAULT_V8R_GLOBS
40
+ * @returns {number} 0 — OK, 1 — помилка spawn, 2 — немає каталогу схем, інше — код v8r
41
+ */
42
+ export function runV8rWithGlobs(globs = DEFAULT_V8R_GLOBS) {
43
+ if (!existsSync(V8R_CATALOG_PATH)) {
44
+ process.stderr.write(
45
+ `run-v8r: не знайдено каталог схем за шляхом ${V8R_CATALOG_PATH} (очікується npm/schemas/v8r-catalog.json у пакеті)\n`
46
+ )
47
+ return 2
48
+ }
29
49
 
30
50
  for (const pattern of globs) {
31
- // Порядок важливий: glob має бути перед -c, інакше yargs у v8r не отримує позиційні patterns.
32
51
  const result = spawnSync('bun', ['x', 'v8r', pattern, '-c', V8R_CATALOG_PATH], {
33
52
  encoding: 'utf8',
34
53
  maxBuffer: 50 * 1024 * 1024,
@@ -38,8 +57,7 @@ if (existsSync(V8R_CATALOG_PATH)) {
38
57
 
39
58
  if (result.error) {
40
59
  process.stderr.write(`${result.error.message}\n`)
41
- process.exitCode = 1
42
- break
60
+ return 1
43
61
  }
44
62
 
45
63
  const exitCode = result.status ?? 1
@@ -50,13 +68,13 @@ if (existsSync(V8R_CATALOG_PATH)) {
50
68
  if (result.stderr?.length) {
51
69
  process.stderr.write(result.stderr)
52
70
  }
53
- process.exitCode = exitCode
54
- break
71
+ return exitCode
55
72
  }
56
73
  }
57
- } else {
58
- process.stderr.write(
59
- `run-v8r: не знайдено каталог схем за шляхом ${V8R_CATALOG_PATH} (очікується npm/schemas/v8r-catalog.json у пакеті)\n`
60
- )
61
- process.exitCode = 2
74
+ return 0
75
+ }
76
+
77
+ if (isRunAsCli()) {
78
+ const globs = process.argv.length > 2 ? process.argv.slice(2) : DEFAULT_V8R_GLOBS
79
+ process.exitCode = runV8rWithGlobs(globs)
62
80
  }