@nitra/cursor 1.8.29 → 1.8.38

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/bin/n-cursor.js CHANGED
@@ -279,6 +279,16 @@ function extractSkillDescription(text) {
279
279
  .trim()
280
280
  }
281
281
 
282
+ /**
283
+ * Підготовка опису skill для вставки в звичайний markdown (заголовок H1, bullet без code fence).
284
+ * Послідовність `<id>` сприймається markdownlint (MD033) як inline HTML — замінюємо на `{id}`.
285
+ * @param {string} desc один рядок з YAML frontmatter SKILL.md
286
+ * @returns {string} той самий рядок після заміни літералу з кутовими дужками навколо id на плейсхолдер у фігурних дужках (MD033).
287
+ */
288
+ function skillDescriptionSafeForMarkdownInline(desc) {
289
+ return desc.replaceAll('<id>', '{id}')
290
+ }
291
+
282
292
  /**
283
293
  * Розгортає в шаблоні блок Mustache {{#section}} … {{/section}} для масиву елементів
284
294
  * @param {string} template вихідний текст шаблону
@@ -382,7 +392,7 @@ async function buildSkillBulletItems(skillIds) {
382
392
  const text = await readFile(skillMdPath, 'utf8')
383
393
  const parsed = extractSkillDescription(text)
384
394
  if (parsed) {
385
- desc = parsed
395
+ desc = skillDescriptionSafeForMarkdownInline(parsed)
386
396
  }
387
397
  }
388
398
  const pathLine = `- \`${SKILLS_DIR}/${dirName}/SKILL.md\``
@@ -442,7 +452,7 @@ async function syncClaudeMd(configRules, configSkills) {
442
452
  if (existsSync(skillMdPath)) {
443
453
  const text = await readFile(skillMdPath, 'utf8')
444
454
  const parsed = extractSkillDescription(text)
445
- if (parsed) desc = parsed
455
+ if (parsed) desc = skillDescriptionSafeForMarkdownInline(parsed)
446
456
  }
447
457
  const ref = `- \`${SKILLS_DIR}/${dirName}/SKILL.md\``
448
458
  lines.push(desc ? `${ref} — ${desc}` : ref, ` Команда: \`/${RULE_PREFIX}${id}\``)
@@ -559,7 +569,8 @@ async function syncCommands(configSkills) {
559
569
  if (existsSync(srcSkillMd)) {
560
570
  try {
561
571
  const raw = await readFile(srcSkillMd, 'utf8')
562
- const desc = extractSkillDescription(raw)
572
+ const descRaw = extractSkillDescription(raw)
573
+ const desc = descRaw ? skillDescriptionSafeForMarkdownInline(descRaw) : ''
563
574
  const header = desc ? `# ${RULE_PREFIX}${id} — ${desc}\n\n` : ''
564
575
  const body = `${header}Виконай інструкції зі скілу \`.cursor/skills/${dirName}/SKILL.md\`.\n`
565
576
  await writeFile(destFile, body, 'utf8')
package/mdc/abie.mdc ADDED
@@ -0,0 +1,56 @@
1
+ ---
2
+ description: Правила для проєктів abinbevefes
3
+ alwaysApply: true
4
+ version: '1.0'
5
+ ---
6
+
7
+ ## k8s
8
+
9
+ Якщо в проекті є k8s deployment, рядом з ним повинен бути:
10
+
11
+ ```yaml title="hc.yaml"
12
+ # yaml-language-server: $schema=https://datreeio.github.io/CRDs-catalog/networking.gke.io/healthcheckpolicy_v1.json
13
+ apiVersion: networking.gke.io/v1
14
+ kind: HealthCheckPolicy
15
+ metadata:
16
+ name: НАЗВА_СЕРВІСУ_ДЛЯ_РОЗГОРТАННЯ
17
+ namespace: dev # буде замінено через kustomize
18
+ spec:
19
+ default:
20
+ config:
21
+ type: HTTP
22
+ httpHealthCheck:
23
+ requestPath: /healthz
24
+ port: 8080
25
+ targetRef:
26
+ group: ''
27
+ kind: Service
28
+ name: НАЗВА_СЕРВІСУ_ДЛЯ_РОЗГОРТАННЯ
29
+ ```
30
+
31
+ і підключення до kustomize. Але у директорії ru повинен бути файл kustomization.yaml з patch, що видаляє ресурс **HealthCheckPolicy**.
32
+
33
+ ```yaml title="kustomization.yaml"
34
+ patches:
35
+ - target:
36
+ kind: HealthCheckPolicy
37
+ patch: |-
38
+ kind: HealthCheckPolicy
39
+ metadata:
40
+ name: НАЗВА_СЕРВІСУ_ДЛЯ_РОЗГОРТАННЯ
41
+ $patch: delete
42
+ ```
43
+
44
+ ## branch
45
+
46
+ В lean-merged-branch.yml список гілок, які повинні бути:
47
+
48
+ ```yaml title=".github/workflows/clean-merged-branch.yml"
49
+ ignore_branches: dev,ua,ru
50
+ ```
51
+
52
+ ## Перевірка
53
+
54
+ `npx @nitra/cursor check abie`
55
+
56
+ Програмна перевірка (**`check-abie.mjs`**) виконується лише якщо у **`.n-cursor.json`** у **`rules`** є **`abie`** — інакше вихід **0** без зауважень (щоб не вимагати **ua**/**ru** у репозиторіях без цього правила).
package/mdc/bun.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Bun як єдиний package manager у монорепо
3
3
  alwaysApply: true
4
- version: '1.4'
4
+ version: '1.6'
5
5
  ---
6
6
 
7
7
  Проект використовує тільки Bun для керування залежностями та запуску скриптів.
@@ -32,6 +32,8 @@ version: '1.4'
32
32
  - `bun add -d <pkg>` для devDependencies
33
33
  - Для одноразових CLI-команд використовуй `bunx <tool>`.
34
34
 
35
+ **Не додавай** у `dependencies` / `devDependencies` пакети, які **використовуються лише як CLI** і їх достатньо викликати через **`bunx <pkg>`** (або **`npx`**, якщо в проєкті так прийнято): наприклад **oxlint**, **jscpd**, **eslint** у корені тощо. Виняток — пакет потрібен як **бібліотека** (імпорт у коді), peer для іншого пакета, або інструмент **не** покривається `bunx` у вашому CI.
36
+
35
37
  Заборонено використовувати:
36
38
 
37
39
  - `npm`
@@ -61,29 +63,16 @@ FROM oven/bun:alpine AS build-env
61
63
 
62
64
  замість образу node
63
65
 
64
- В Github actions bun повинен налаштований так:
66
+ У **GitHub Actions** не вставляй у workflow окремі кроки **`actions/setup-node`**, **`oven-sh/setup-bun`**, **`actions/cache`** та **`bun install`** — їх **заборонено** дублювати в кожному job; завжди використовуй **локальний composite** (деталі й заборона дублювання — **ga.mdc**). Під капотом composite уже містить Node **24**, Bun, кеш і **`bun install --frozen-lockfile`**.
67
+
68
+ Після **`actions/checkout@v6`** (`persist-credentials: false`):
65
69
 
66
70
  ```yaml
67
- - uses: actions/setup-node@v6
68
- with:
69
- node-version: '24'
70
-
71
- - uses: oven-sh/setup-bun@v2
72
-
73
- - name: Cache Bun dependencies
74
- uses: actions/cache@v5
75
- with:
76
- path: |
77
- ~/.bun/install/cache
78
- node_modules
79
- key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
80
- restore-keys: |
81
- ${{ runner.os }}-bun-
82
-
83
- - name: Install dependencies
84
- run: bun install --frozen-lockfile
71
+ - uses: ./.github/actions/setup-bun-deps
85
72
  ```
86
73
 
74
+ Якщо в репозиторії action збережено під **`./npm/github-actions/setup-bun-deps`**, у `uses:` вкажи цей шлях замість `.github/actions/…` (**ga.mdc**).
75
+
87
76
  ## Перевірка
88
77
 
89
78
  `npx @nitra/cursor check bun`
package/mdc/js-lint.mdc CHANGED
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  description: Перевірка JavaScript коду
3
3
  alwaysApply: true
4
- version: '1.9'
4
+ version: '1.11'
5
5
  ---
6
6
 
7
- **oxlint**, **ESLint**, **jscpd**. У скрипті **`lint-js`:** `oxlint` (без `bunx`), **`bunx eslint`**, **`bunx jscpd`**; у CI `bunx oxlint` / `bunx eslint` / `bunx jscpd`. Без **prettier** і **@nitra/prettier-config**. Достатньо **`@nitra/eslint-config`** у devDependencies; пакети oxlint/eslint/jscpd не додавай без потреби монорепо.
7
+ **oxlint**, **ESLint**, **jscpd**. У скрипті **`lint-js`** і в CI — **`bunx oxlint`**, **`bunx eslint`**, **`bunx jscpd`** (у CI без **`--fix`** для oxlint/eslint див. приклад workflow нижче). Без **prettier** і **@nitra/prettier-config**. Достатньо **`@nitra/eslint-config`** у devDependencies; пакети oxlint/eslint/jscpd не додавай без потреби монорепо.
8
8
 
9
9
  ```json title=".vscode/extensions.json"
10
10
  {
@@ -19,7 +19,7 @@ version: '1.9'
19
19
  ```json title="package.json"
20
20
  {
21
21
  "scripts": {
22
- "lint-js": "oxlint --fix && bunx eslint --fix . && bunx jscpd ."
22
+ "lint-js": "bunx oxlint --fix && bunx eslint --fix . && bunx jscpd ."
23
23
  },
24
24
  "devDependencies": {
25
25
  "@nitra/eslint-config": "^3.4.0"
@@ -38,7 +38,7 @@ version: '1.9'
38
38
  "exitCode": 1,
39
39
  "reporters": ["console"],
40
40
  "minLines": 25,
41
- "ignore": ["**/dist/**",]
41
+ "ignore": ["**/dist/**"]
42
42
  }
43
43
  ```
44
44
 
package/mdc/k8s.mdc CHANGED
@@ -206,7 +206,7 @@ resources: {}
206
206
  - Обхід з пропуском `node_modules`, `.git`, `dist`, `coverage`, `.turbo`, `.next`.
207
207
  - **`file:`** у `$schema` — URL до apiVersion/kind не звіряється; **`https:`** — kustomization за іменем файлу → Schema Store; далі перевіряється **`EXPLICIT_K8S_SCHEMAS`** (`Map`: `apiVersion` + `kind` + `type`, для записів без `type` у маніфесті — третій компонент **`*`**); потім `v1` → yannh; група з `YANNH_GROUPS` → yannh; інакше → datree (GitHub Pages), крім рядків явної таблиці (наприклад **InfisicalSecret** — raw на `main`).
208
208
  - У кожному YAML-документі з **`kind: Deployment`** — парсинг через **`yaml`**: у кожного елемента **`spec.template.spec.containers[]`** має існувати ключ **`resources`** зі значенням-об'єктом (допускається порожній **`{}`**); у кожного контейнера — **`imagePullPolicy: Always`**.
209
- - У файлах, ім’я яких **не** `kustomization.yaml` / `kustomization.yml`, у кожному документі — заборона поля **`metadata.namespace`** (**namespace** задається в Kustomize).
209
+ - У файлах, ім’я яких **не** `kustomization.yaml`.
210
210
  - Документи з **`kind: Ingress`** — заборонені (потрібен перехід на Gateway API, див. розділ **Ingress → Gateway API**).
211
211
  - Якщо в будь-якому файлі під **`k8s`** є **`kind: HealthCheckPolicy`**, серед файлів має бути **`ru/kustomization.yaml`** (сегмент шляху **`ru`** перед іменем файлу), а його вміст — patch видалення **HealthCheckPolicy** з **`$patch: delete`** (див. той самий розділ).
212
212
  - Заборона шляхів **`…/k8s/dev/…`** (окремої директорії **`dev`** під **`k8s`** не має бути).
package/mdc/text.mdc CHANGED
@@ -28,9 +28,9 @@ version: '1.24'
28
28
  }
29
29
  ```
30
30
 
31
- **`package.json`:** скрипт **`lint-text`** і devDependencies **`@nitra/cspell-dict`**. Для української додай **`@cspell/dict-uk-ua`**. **`markdownlint-cli2`** викликай у `lint-text` лише через **`bunx markdownlint-cli2`**, не додавай пакет до devDependencies. **`v8r`** лише через **`bun x v8r`** (зазвичай **`bunx v8r`**), не в devDependencies. Окремий пакет **`markdownlint`** не потрібний.
31
+ **`package.json`:** скрипт **`lint-text`** і devDependencies **`@nitra/cspell-dict`**. Для української додай **`@cspell/dict-uk-ua`**. **`markdownlint-cli2`** викликай у `lint-text` лише через **`bunx markdownlint-cli2`**, не додавай пакет до devDependencies. **`v8r`** лише через **`bunx v8r`** (зазвичай **`bunx v8r`**), не в devDependencies. Окремий пакет **`markdownlint`** не потрібний.
32
32
 
33
- У v8r **немає** прапорця тихого режиму; рекомендовано скрипт **`run-v8r.mjs`** з репозиторію пакета `@nitra/cursor` (`npm/scripts/run-v8r.mjs`): один виклик у `lint-text` — під капотом послідовні **`bun x 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/…`.
33
+ У 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/…`.
34
34
 
35
35
  ```json title="package.json"
36
36
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.29",
3
+ "version": "1.8.38",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -0,0 +1,468 @@
1
+ /**
2
+ * Перевіряє відповідність проєкту правилу abie.mdc (проєкти abinbevefes).
3
+ *
4
+ * Застосовується лише якщо у **`.n-cursor.json`** у масиві **`rules`** є **`abie`** — інакше вихід **0**
5
+ * без перевірок (щоб не суперечити типовому **ga.mdc** з **`ignore_branches: main,dev`**).
6
+ *
7
+ * **Гілки:** у **`.github/workflows/clean-merged-branch.yml`** у кроці з
8
+ * **`phpdocker-io/github-actions-delete-abandoned-branches`** у **`with.ignore_branches`** мають бути
9
+ * **dev**, **ua** та **ru** (разом з іншими гілками, якщо потрібно).
10
+ *
11
+ * **k8s:** якщо під деревом із сегментом **`k8s`** є YAML з **`kind: Deployment`**, у тій самій директорії
12
+ * має існувати **`hc.yaml`** із **`HealthCheckPolicy`** (**`networking.gke.io/v1`**), modeline **`$schema`**
13
+ * як у abie.mdc, **`/healthz`**, порт **8080**, **`targetRef`** на **Service** з тим самим **`metadata.name`**.
14
+ * Якщо в дереві **k8s** є **HealthCheckPolicy**, перевіряється **`ru/kustomization.yaml`** з patch **`$patch: delete`**
15
+ * (узгоджено з **k8s.mdc** / **check-k8s.mjs**).
16
+ */
17
+ import { existsSync } from 'node:fs'
18
+ import { readFile } from 'node:fs/promises'
19
+ import { dirname, join, relative } from 'node:path'
20
+
21
+ import { parseAllDocuments } from 'yaml'
22
+
23
+ import { isRuKustomizationPath, pathHasK8sSegment, ruKustomizationHasHealthCheckDeletePatch } from './check-k8s.mjs'
24
+ import { pass } from './utils/pass.mjs'
25
+ import { flattenWorkflowSteps, getStepUses, parseWorkflowYaml } from './utils/gha-workflow.mjs'
26
+ import { walkDir } from './utils/walkDir.mjs'
27
+
28
+ const CONFIG_FILE = '.n-cursor.json'
29
+
30
+ /** Очікуваний URL **`$schema`** для **hc.yaml** (abie.mdc). */
31
+ export const ABIE_HC_SCHEMA_URL = 'https://datreeio.github.io/CRDs-catalog/networking.gke.io/healthcheckpolicy_v1.json'
32
+
33
+ const MODELINE_RE = /^#\s*yaml-language-server:\s*\$schema=(\S+)\s*$/
34
+
35
+ /** Гілки, які мають бути в **`ignore_branches`** за abie.mdc. */
36
+ export const ABIE_REQUIRED_IGNORE_BRANCHES = ['dev', 'ua', 'ru']
37
+
38
+ /**
39
+ * Чи увімкнено правило **abie** у конфігу репозиторію.
40
+ * @param {string} root корінь репозиторію (cwd)
41
+ * @returns {Promise<boolean>} true, якщо **rules** містить **abie**
42
+ */
43
+ export async function isAbieRuleEnabled(root) {
44
+ const p = join(root, CONFIG_FILE)
45
+ if (!existsSync(p)) {
46
+ return false
47
+ }
48
+ let raw
49
+ try {
50
+ raw = await readFile(p, 'utf8')
51
+ } catch {
52
+ return false
53
+ }
54
+ let cfg
55
+ try {
56
+ cfg = JSON.parse(raw)
57
+ } catch {
58
+ return false
59
+ }
60
+ const rules = cfg?.rules
61
+ if (!Array.isArray(rules)) {
62
+ return false
63
+ }
64
+ return rules.some(r => String(r).trim().toLowerCase() === 'abie')
65
+ }
66
+
67
+ /**
68
+ * Розбирає **`ignore_branches`** з workflow **clean-merged-branch** (крок delete-abandoned-branches).
69
+ * @param {string} content вміст **.yml**
70
+ * @returns {string | null} рядок **ignore_branches** або **null**
71
+ */
72
+ export function parseCleanMergedIgnoreBranches(content) {
73
+ const root = parseWorkflowYaml(content)
74
+ if (!root) {
75
+ return null
76
+ }
77
+ for (const { step } of flattenWorkflowSteps(root)) {
78
+ const uses = getStepUses(step)
79
+ if (uses.includes('phpdocker-io/github-actions-delete-abandoned-branches')) {
80
+ const w = step.with
81
+ if (w && typeof w === 'object' && !Array.isArray(w)) {
82
+ const ib = /** @type {Record<string, unknown>} */ (w).ignore_branches
83
+ if (typeof ib === 'string') {
84
+ return ib
85
+ }
86
+ }
87
+ }
88
+ }
89
+ return null
90
+ }
91
+
92
+ /**
93
+ * Чи рядок **ignore_branches** містить усі гілки з **required** (для abie — dev, ua, ru).
94
+ * @param {string} ignoreBranches значення **ignore_branches**
95
+ * @param {string[]} required імена гілок (нижній регістр для порівняння)
96
+ * @returns {boolean} true, якщо всі **required** присутні як окремі токени
97
+ */
98
+ export function ignoreBranchesIncludesRequired(ignoreBranches, required) {
99
+ const parts = new Set(
100
+ ignoreBranches
101
+ .split(',')
102
+ .map(s => s.trim().toLowerCase())
103
+ .filter(Boolean)
104
+ )
105
+ return required.every(r => parts.has(r.toLowerCase()))
106
+ }
107
+
108
+ /**
109
+ * Збирає абсолютні шляхи до **.yaml** / **.yml** під деревом, де є сегмент **k8s**.
110
+ * @param {string} root корінь репозиторію
111
+ * @returns {Promise<string[]>} відсортовані шляхи
112
+ */
113
+ async function findK8sYamlFiles(root) {
114
+ /** @type {string[]} */
115
+ const out = []
116
+ await walkDir(root, p => {
117
+ if (!pathHasK8sSegment(p)) {
118
+ return
119
+ }
120
+ if (!/\.ya?ml$/iu.test(p)) {
121
+ return
122
+ }
123
+ out.push(p)
124
+ })
125
+ return [...out].toSorted((a, b) => a.localeCompare(b))
126
+ }
127
+
128
+ /**
129
+ * Чи документ — **Deployment**.
130
+ * @param {unknown} obj корінь YAML-документа
131
+ * @returns {boolean} true, якщо **kind** документа — **Deployment**
132
+ */
133
+ function isDeploymentDoc(obj) {
134
+ return (
135
+ obj !== null &&
136
+ typeof obj === 'object' &&
137
+ !Array.isArray(obj) &&
138
+ /** @type {Record<string, unknown>} */ (obj).kind === 'Deployment'
139
+ )
140
+ }
141
+
142
+ /**
143
+ * Директорії, де є хоча б один **Deployment** у файлах **k8s**.
144
+ * @param {string} root корінь cwd
145
+ * @param {string[]} yamlAbs абсолютні шляхи yaml під k8s
146
+ * @param {(msg: string) => void} fail реєстрація помилки парсингу
147
+ * @returns {Promise<Set<string>>} абсолютні шляхи директорій
148
+ */
149
+ async function collectDeploymentDirs(root, yamlAbs, fail) {
150
+ /** @type {Set<string>} */
151
+ const dirs = new Set()
152
+ for (const abs of yamlAbs) {
153
+ let raw
154
+ let readOk = false
155
+ try {
156
+ raw = await readFile(abs, 'utf8')
157
+ readOk = true
158
+ } catch (error) {
159
+ const msg = error instanceof Error ? error.message : String(error)
160
+ fail(`${relative(root, abs) || abs}: не вдалося прочитати (${msg})`)
161
+ }
162
+ if (readOk) {
163
+ const body = stripBom(raw)
164
+ const lines = body.split(/\r?\n/u)
165
+ const first = lines[0] ?? ''
166
+ const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
167
+ /** @type {import('yaml').Document[]} */
168
+ let docs
169
+ let parseOk = false
170
+ try {
171
+ docs = parseAllDocuments(rest)
172
+ parseOk = true
173
+ } catch (error) {
174
+ const msg = error instanceof Error ? error.message : String(error)
175
+ fail(`${relative(root, abs) || abs}: YAML (${msg})`)
176
+ }
177
+ if (parseOk) {
178
+ for (const doc of docs) {
179
+ if (doc.errors.length === 0) {
180
+ const obj = doc.toJSON()
181
+ if (isDeploymentDoc(obj)) {
182
+ dirs.add(dirname(abs))
183
+ }
184
+ }
185
+ }
186
+ }
187
+ }
188
+ }
189
+ return dirs
190
+ }
191
+
192
+ /**
193
+ * Прибирає BOM на початку файлу.
194
+ * @param {string} s вміст
195
+ * @returns {string} той самий рядок без BOM (U+FEFF) на початку
196
+ */
197
+ function stripBom(s) {
198
+ return s.startsWith('\uFEFF') ? s.slice(1) : s
199
+ }
200
+
201
+ /**
202
+ * Перевіряє **hc.yaml** на відповідність abie.mdc.
203
+ * @param {string} raw повний текст файлу
204
+ * @param {string} relPath відносний шлях для повідомлень
205
+ * @returns {string | null} текст помилки або **null**
206
+ */
207
+ export function validateAbieHcYaml(raw, relPath) {
208
+ const body = stripBom(raw)
209
+ const lines = body.split(/\r?\n/u)
210
+ if (lines.length === 0 || lines[0].trim() === '') {
211
+ return `${relPath}: перший рядок порожній — потрібен # yaml-language-server: $schema=… (abie.mdc)`
212
+ }
213
+ const m = lines[0].match(MODELINE_RE)
214
+ if (!m) {
215
+ return `${relPath}: перший рядок має бути modeline $schema (abie.mdc)`
216
+ }
217
+ if (m[1] !== ABIE_HC_SCHEMA_URL) {
218
+ return `${relPath}: $schema має бути\n ${ABIE_HC_SCHEMA_URL}\n (abie.mdc)`
219
+ }
220
+ const yamlBody = lines
221
+ .slice(1)
222
+ .join('\n')
223
+ .replace(/^\s*\n/u, '')
224
+ /** @type {import('yaml').Document[]} */
225
+ let docs
226
+ try {
227
+ docs = parseAllDocuments(yamlBody)
228
+ } catch (error) {
229
+ const msg = error instanceof Error ? error.message : String(error)
230
+ return `${relPath}: не вдалося розібрати YAML (${msg})`
231
+ }
232
+ /** @type {Record<string, unknown> | null} */
233
+ let policy = null
234
+ for (const doc of docs) {
235
+ if (doc.errors.length > 0) {
236
+ return `${relPath}: YAML: ${doc.errors.map(e => e.message).join('; ')}`
237
+ }
238
+ const obj = doc.toJSON()
239
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
240
+ const rec = /** @type {Record<string, unknown>} */ (obj)
241
+ if (rec.kind === 'HealthCheckPolicy') {
242
+ policy = rec
243
+ break
244
+ }
245
+ }
246
+ }
247
+ if (!policy) {
248
+ return `${relPath}: очікується документ kind: HealthCheckPolicy (abie.mdc)`
249
+ }
250
+ if (policy.apiVersion !== 'networking.gke.io/v1') {
251
+ return `${relPath}: apiVersion має бути networking.gke.io/v1 (abie.mdc)`
252
+ }
253
+ const meta = policy.metadata
254
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) {
255
+ return `${relPath}: відсутній metadata (abie.mdc)`
256
+ }
257
+ const name = /** @type {Record<string, unknown>} */ (meta).name
258
+ if (typeof name !== 'string' || name.trim() === '') {
259
+ return `${relPath}: metadata.name має бути непорожнім рядком (abie.mdc)`
260
+ }
261
+ const spec = policy.spec
262
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
263
+ return `${relPath}: відсутній spec (abie.mdc)`
264
+ }
265
+ const def = /** @type {Record<string, unknown>} */ (spec).default
266
+ if (def === null || typeof def !== 'object' || Array.isArray(def)) {
267
+ return `${relPath}: відсутній spec.default (abie.mdc)`
268
+ }
269
+ const config = /** @type {Record<string, unknown>} */ (def).config
270
+ if (config === null || typeof config !== 'object' || Array.isArray(config)) {
271
+ return `${relPath}: відсутній spec.default.config (abie.mdc)`
272
+ }
273
+ if (config.type !== 'HTTP') {
274
+ return `${relPath}: spec.default.config.type має бути HTTP (abie.mdc)`
275
+ }
276
+ const httpHc = /** @type {Record<string, unknown>} */ (config).httpHealthCheck
277
+ if (httpHc === null || typeof httpHc !== 'object' || Array.isArray(httpHc)) {
278
+ return `${relPath}: відсутній httpHealthCheck (abie.mdc)`
279
+ }
280
+ if (httpHc.requestPath !== '/healthz') {
281
+ return `${relPath}: httpHealthCheck.requestPath має бути /healthz (abie.mdc)`
282
+ }
283
+ if (httpHc.port !== 8080) {
284
+ return `${relPath}: httpHealthCheck.port має бути 8080 (abie.mdc)`
285
+ }
286
+ const targetRef = /** @type {Record<string, unknown>} */ (spec).targetRef
287
+ if (targetRef === null || typeof targetRef !== 'object' || Array.isArray(targetRef)) {
288
+ return `${relPath}: відсутній targetRef (abie.mdc)`
289
+ }
290
+ if (targetRef.kind !== 'Service') {
291
+ return `${relPath}: targetRef.kind має бути Service (abie.mdc)`
292
+ }
293
+ const svcName = targetRef.name
294
+ if (typeof svcName !== 'string' || svcName !== name) {
295
+ return `${relPath}: targetRef.name має збігатися з metadata.name (${name}) (abie.mdc)`
296
+ }
297
+ return null
298
+ }
299
+
300
+ /**
301
+ * Збирає відносні шляхи файлів із **HealthCheckPolicy** у дереві k8s.
302
+ * @param {string} root корінь
303
+ * @param {string[]} yamlAbs абсолютні шляхи
304
+ * @returns {Promise<string[]>} унікальні відносні шляхи yaml із **HealthCheckPolicy**
305
+ */
306
+ async function collectHealthCheckPolicyRelPaths(root, yamlAbs) {
307
+ /** @type {string[]} */
308
+ const out = []
309
+ for (const abs of yamlAbs) {
310
+ const rel = relative(root, abs).replaceAll('\\', '/') || abs
311
+ let raw
312
+ try {
313
+ raw = await readFile(abs, 'utf8')
314
+ } catch {
315
+ raw = null
316
+ }
317
+ if (raw !== null) {
318
+ const body = stripBom(raw)
319
+ const lines = body.split(/\r?\n/u)
320
+ const first = lines[0] ?? ''
321
+ const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
322
+ try {
323
+ const docs = parseAllDocuments(rest)
324
+ for (const doc of docs) {
325
+ if (doc.errors.length === 0) {
326
+ const obj = doc.toJSON()
327
+ if (obj !== null && typeof obj === 'object' && !Array.isArray(obj)) {
328
+ const rec = /** @type {Record<string, unknown>} */ (obj)
329
+ if (rec.kind === 'HealthCheckPolicy' && !out.includes(rel)) {
330
+ out.push(rel)
331
+ }
332
+ }
333
+ }
334
+ }
335
+ } catch {
336
+ /* пропускаємо пошкоджені файли — їх ловить check-k8s */
337
+ }
338
+ }
339
+ }
340
+ return out
341
+ }
342
+
343
+ /**
344
+ * Якщо є **HealthCheckPolicy**, вимагає **ru/kustomization.yaml** з patch видалення (як **check-k8s**).
345
+ * @param {string} root корінь
346
+ * @param {string[]} yamlFilesAbs абсолютні шляхи yaml k8s
347
+ * @param {string[]} healthCheckPolicyRelativePaths відносні шляхи
348
+ * @param {(msg: string) => void} fail callback
349
+ * @returns {Promise<void>}
350
+ */
351
+ async function ensureRuKustomizationHealthCheckDelete(root, yamlFilesAbs, healthCheckPolicyRelativePaths, fail) {
352
+ if (healthCheckPolicyRelativePaths.length === 0) {
353
+ return
354
+ }
355
+ const ruAbsList = yamlFilesAbs.filter(abs => isRuKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
356
+ if (ruAbsList.length === 0) {
357
+ fail(
358
+ `Знайдено HealthCheckPolicy у ${healthCheckPolicyRelativePaths.join(', ')} — додай ru/kustomization.yaml з patch видалення (abie.mdc)`
359
+ )
360
+ return
361
+ }
362
+ for (const abs of ruAbsList) {
363
+ let raw
364
+ try {
365
+ raw = await readFile(abs, 'utf8')
366
+ } catch (error) {
367
+ const msg = error instanceof Error ? error.message : String(error)
368
+ fail(`${relative(root, abs) || abs}: не вдалося прочитати (${msg})`)
369
+ return
370
+ }
371
+ if (ruKustomizationHasHealthCheckDeletePatch(raw)) {
372
+ return
373
+ }
374
+ }
375
+ fail(
376
+ 'Є HealthCheckPolicy, але жоден ru/kustomization.yaml не містить очікуваного patch видалення (kind: HealthCheckPolicy, metadata.name, $patch: delete) — abie.mdc'
377
+ )
378
+ }
379
+
380
+ /**
381
+ * Перевіряє відповідність проєкту правилам abie.mdc.
382
+ * @returns {Promise<number>} 0 — OK, 1 — є порушення
383
+ */
384
+ export async function check() {
385
+ let exitCode = 0
386
+ const fail = msg => {
387
+ console.log(` ❌ ${msg}`)
388
+ exitCode = 1
389
+ }
390
+
391
+ const root = process.cwd()
392
+ const enabled = await isAbieRuleEnabled(root)
393
+ if (!enabled) {
394
+ pass(`Правило abie не увімкнено в ${CONFIG_FILE} (rules) — перевірку пропущено`)
395
+ return 0
396
+ }
397
+
398
+ pass('Правило abie увімкнено — виконуємо перевірки')
399
+
400
+ const cleanMergedPath = join(root, '.github/workflows/clean-merged-branch.yml')
401
+ if (existsSync(cleanMergedPath)) {
402
+ /** @type {string | undefined} */
403
+ let wfRaw
404
+ try {
405
+ wfRaw = await readFile(cleanMergedPath, 'utf8')
406
+ } catch (error) {
407
+ const msg = error instanceof Error ? error.message : String(error)
408
+ fail(`Не вдалося прочитати clean-merged-branch.yml (${msg})`)
409
+ }
410
+ if (wfRaw !== undefined) {
411
+ const ib = parseCleanMergedIgnoreBranches(wfRaw)
412
+ if (ib === null || ib.trim() === '') {
413
+ fail(
414
+ 'clean-merged-branch.yml: не знайдено with.ignore_branches у кроці phpdocker-io/github-actions-delete-abandoned-branches (abie.mdc)'
415
+ )
416
+ } else if (ignoreBranchesIncludesRequired(ib, ABIE_REQUIRED_IGNORE_BRANCHES)) {
417
+ pass('clean-merged-branch.yml: ignore_branches містить dev, ua, ru')
418
+ } else {
419
+ fail(
420
+ `clean-merged-branch.yml: ignore_branches має містити dev, ua та ru (зараз: ${JSON.stringify(ib)}) — abie.mdc`
421
+ )
422
+ }
423
+ }
424
+ } else {
425
+ fail(`Відсутній ${cleanMergedPath} — потрібен для ignore_branches (abie.mdc)`)
426
+ }
427
+
428
+ const yamlFiles = await findK8sYamlFiles(root)
429
+ const deploymentDirs = await collectDeploymentDirs(root, yamlFiles, fail)
430
+
431
+ if (deploymentDirs.size > 0) {
432
+ pass(`Знайдено Deployment у ${deploymentDirs.size} директорія(ї/й) k8s — перевіряємо hc.yaml`)
433
+ for (const dir of [...deploymentDirs].toSorted((a, b) => a.localeCompare(b))) {
434
+ const hcAbs = join(dir, 'hc.yaml')
435
+ const relHc = relative(root, hcAbs).replaceAll('\\', '/') || 'hc.yaml'
436
+ if (existsSync(hcAbs)) {
437
+ let hcRaw
438
+ let hcReadOk = false
439
+ try {
440
+ hcRaw = await readFile(hcAbs, 'utf8')
441
+ hcReadOk = true
442
+ } catch (error) {
443
+ const msg = error instanceof Error ? error.message : String(error)
444
+ fail(`${relHc}: не вдалося прочитати (${msg})`)
445
+ }
446
+ if (hcReadOk) {
447
+ const v = validateAbieHcYaml(hcRaw, relHc)
448
+ if (v === null) {
449
+ pass(`${relHc}: відповідає abie.mdc`)
450
+ } else {
451
+ fail(v)
452
+ }
453
+ }
454
+ } else {
455
+ fail(
456
+ `${relative(root, dir) || dir}: є Deployment, але немає hc.yaml поруч — додай HealthCheckPolicy (abie.mdc)`
457
+ )
458
+ }
459
+ }
460
+ } else {
461
+ pass('Немає Deployment у дереві k8s — перевірку hc.yaml пропущено')
462
+ }
463
+
464
+ const healthCheckPolicyRelativePaths = await collectHealthCheckPolicyRelPaths(root, yamlFiles)
465
+ await ensureRuKustomizationHealthCheckDelete(root, yamlFiles, healthCheckPolicyRelativePaths, fail)
466
+
467
+ return exitCode
468
+ }
@@ -9,11 +9,11 @@
9
9
  import { existsSync } from 'node:fs'
10
10
  import { readFile } from 'node:fs/promises'
11
11
 
12
- import { pass } from './utils/pass.mjs'
13
12
  import { parseWorkflowYaml, verifyLintJsWorkflowStructure } from './utils/gha-workflow.mjs'
13
+ import { pass } from './utils/pass.mjs'
14
14
 
15
- /** Очікуваний локальний скрипт (oxlint без bunx; eslint/jscpd через bunx). */
16
- export const CANONICAL_LINT_JS = 'oxlint --fix && bunx eslint --fix . && bunx jscpd .'
15
+ /** Очікуваний локальний скрипт. */
16
+ export const CANONICAL_LINT_JS = 'bunx oxlint --fix && bunx eslint --fix . && bunx jscpd .'
17
17
 
18
18
  /** Мінімальні рекомендації розширень редактора з js-lint.mdc (eslint, oxlint, GA). */
19
19
  export const REQUIRED_VSCODE_EXTENSIONS = ['dbaeumer.vscode-eslint', 'github.vscode-github-actions', 'oxc.oxc-vscode']
@@ -28,14 +28,12 @@ export function normalizeLintJsScript(s) {
28
28
  }
29
29
 
30
30
  /**
31
- * Чи рядок `lint-js` збігається з каноном і без `bunx oxlint`.
31
+ * Чи рядок `lint-js` збігається з каноном (`bunx oxlint`, `bunx eslint`, `bunx jscpd`).
32
32
  * @param {string} script значення `scripts.lint-js` з package.json
33
33
  * @returns {boolean} true, якщо рядок канонічний
34
34
  */
35
35
  export function isCanonicalLintJs(script) {
36
- const n = normalizeLintJsScript(script)
37
- if (n.includes('bunx oxlint')) return false
38
- return n === CANONICAL_LINT_JS
36
+ return normalizeLintJsScript(script) === CANONICAL_LINT_JS
39
37
  }
40
38
 
41
39
  /**
@@ -89,7 +87,7 @@ export async function check() {
89
87
  pass(`lint-js збігається з каноном: ${CANONICAL_LINT_JS}`)
90
88
  } else {
91
89
  fail(
92
- `lint-js має бути рівно: "${CANONICAL_LINT_JS}" (oxlint без bunx; див. js-lint.mdc / check-js-lint.mjs). Зараз: ${JSON.stringify(normalizeLintJsScript(lintJs))}`
90
+ `lint-js має бути рівно: "${CANONICAL_LINT_JS}" (див. js-lint.mdc / check-js-lint.mjs). Зараз: ${JSON.stringify(normalizeLintJsScript(lintJs))}`
93
91
  )
94
92
  }
95
93
  } else {
@@ -182,7 +180,7 @@ export async function check() {
182
180
  }
183
181
  }
184
182
  if (content.includes('bunx oxlint') && /bunx\s+oxlint[^\n]*--fix/u.test(content)) {
185
- fail('lint-js.yml: у CI не використовуй oxlint --fix (лише bunx oxlint)')
183
+ fail('lint-js.yml: у CI не використовуй bunx oxlint --fix (лише bunx oxlint)')
186
184
  }
187
185
  if (content.includes('eslint --fix')) {
188
186
  fail('lint-js.yml: у CI не використовуй eslint --fix (лише bunx eslint .)')
@@ -2,7 +2,7 @@
2
2
  * Тихий запуск v8r для усіх типів файлів, які підтримує v8r (json, json5, yaml, yml, toml).
3
3
  *
4
4
  * Один виклик цього скрипта з `lint-text` замість чотирьох окремих викликів v8r: під капотом для
5
- * кожного glob окремий `bun x v8r`, бо v8r у одному процесі падає з кодом 98, якщо хоч один із
5
+ * кожного glob окремий `bunx v8r`, бо v8r у одному процесі падає з кодом 98, якщо хоч один із
6
6
  * переданих глобів не знаходить файлів — тоді решта розширень не перевіряються.
7
7
  *
8
8
  * Каталог схем `@nitra/cursor` (`v8r-catalog.json` у каталозі `schemas` пакета) передається в v8r
@@ -0,0 +1,86 @@
1
+ ---
2
+ name: n-mdc-check
3
+ description: >-
4
+ Проаналізувати правило в npm/mdc і перенести максимум перевірюваної логіки в check-{id}.mjs;
5
+ залишити в .mdc лише те, що не автоматизується або служить контекстом для агента
6
+ ---
7
+
8
+ # n-mdc-check — від правила до скрипта
9
+
10
+ Навичка для роботи з правилами пакета `@nitra/cursor`: **перевірювані критерії** мають жити в **`npm/scripts/check-<id>.mjs`**, а **`npm/mdc/<id>.mdc`** (і дзеркало **`.cursor/rules/n-<id>.mdc`**) — бути **коротким орієнтиром**: навіщо правило, як виправляти порушення, яка команда підтверджує відповідність.
11
+
12
+ Канонічні вимоги до скриптів, тестів і AST — **`.cursor/rules/scripts.mdc`**.
13
+
14
+ ## Коли застосовувати
15
+
16
+ - Додали або змінили вимоги в **`npm/mdc/<id>.mdc`** (або локальному **`n-<id>.mdc`**).
17
+ - У правилі багато «має містити», «заборонено», точних фрагментів файлів — і агенти повторюють перевірку вручну.
18
+ - Потрібно зменшити обсяг `.mdc` і уникнути роз’їзду тексту й фактичної перевірки.
19
+
20
+ ## Ідентифікатор правила
21
+
22
+ - Файл правила в пакеті: **`npm/mdc/<id>.mdc`** → скрипт: **`npm/scripts/check-<id>.mjs`**, команда CLI: **`npx @nitra/cursor check <id>`**.
23
+ - CLI підхоплює всі **`check-*.mjs`** з каталогу `scripts` пакета; окремої реєстрації не потрібно.
24
+ - Якщо правило згадане в **`AGENTS.md`**, для нього доречна programmatic перевірка — має існувати відповідний **`check-<id>.mjs`** (коли вимоги формалізовані).
25
+
26
+ ## Workflow
27
+
28
+ ### 1. Базова лінія
29
+
30
+ ```bash
31
+ npx @nitra/cursor check <id>
32
+ ```
33
+
34
+ Зафіксуй поточний вивід (якщо скрипт уже є).
35
+
36
+ ### 2. Розбір тексту правила
37
+
38
+ Прочитай **`npm/mdc/<id>.mdc`** повністю. Для кожної вимоги виріши:
39
+
40
+ | Тип | Куди |
41
+ | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
42
+ | Перевірювана структура (файли, поля JSON/YAML, наявність ключів, заборонені шляхи) | **`check-<id>.mjs`** |
43
+ | Семантика JS/TS у файлах | **`check-<id>.mjs`** через **Oxc AST** (`oxc-parser`), не «сліпі» regex по всьому файлу |
44
+ | Вміст у Vue SFC / MDX / Astro | Спочатку **виділи** валідний JS/TS-фрагмент, потім AST; regex — лише для ізоляції блоків |
45
+ | Не-JS конфіги, сирі workflow | Текстовий/структурний парсинг або обґрунтований regex; у JSDoc коротко **чому** не AST |
46
+ | Смак, архітектура, «навіщо так», поради без однозначного критерію | Залиш у **`.mdc`** |
47
+
48
+ ### 3. Скрипт
49
+
50
+ - **Новий** `check-<id>.mjs`: орієнтуйся на наявні файли в **`npm/scripts/`** (наприклад **`check-bun.mjs`**) — стиль **`fail` / `pass`**, **`export async function check()`**, exit code **0 / 1**.
51
+ - **На початку файлу** (перед `import`) — **багаторядковий** верхній JSDoc **українською**: що перевіряє файл; між смисловими блоками — рядок `*`; перші рядки без службових префіксів — одразу зрозуміло призначення.
52
+ - **JSDoc** до експортованих функцій і нетривіальних внутрішніх функцій.
53
+ - Повідомлення **`fail`**: конкретно, що не так і як виправити (як у інших check-скриптах).
54
+ - Залежність **`oxc-parser`** не додавай у корінь споживача — вона в **`dependencies`** пакета **`@nitra/cursor`** (див. **dev-dep** / **scripts.mdc**).
55
+
56
+ ### 4. Тести
57
+
58
+ Якщо логіка нетривіальна або її легко зламати — додай **`npm/tests/check-<id>.test.mjs`** (або розшир наявні тести). Після змін:
59
+
60
+ ```bash
61
+ cd npm && bun test
62
+ ```
63
+
64
+ ### 5. Спрощення `.mdc`
65
+
66
+ - Прибери з правила **дубль** детальних умов, які тепер у скрипті; залиш **короткий зміст**, мотивацію, посилання на файли/конвенції.
67
+ - Потрібно зберегти (або додати) секцію **«Перевірка»** з **`npx @nitra/cursor check <id>`** (або повним ім’ям правила з `AGENTS.md`).
68
+ - Якщо оновлюєш пакетне правило, **синхронно** онови дзеркало **`.cursor/rules/n-<id>.mdc`**, щоб текст не розходився (як прийнято в цьому репозиторії).
69
+
70
+ ### 6. Верифікація та версія пакета
71
+
72
+ ```bash
73
+ npx @nitra/cursor check <id>
74
+ ```
75
+
76
+ Після змін у **`npm/`** (у тому числі `skills/`, `scripts/`, `mdc/`) підвищ **patch**-версію в **`npm/package.json`** на **один** крок відносно вже запланованого релізу (див. **n-npm-module**).
77
+
78
+ ## Антипатерни
79
+
80
+ - Залишати в `.mdc` великі «еталонні» YAML/JSON, якщо перевірка може читати очікувану структуру з репозиторію або константи в скрипті.
81
+ - Оновлювати лише текст правила без оновлення **`check-<id>.mjs`**, коли для правила вже є скрипт або з’явилась нова перевірювана умова.
82
+ - Дублювати однакову логіку в кількох check-скриптах замість спільної утиліти в **`npm/scripts/utils/`**.
83
+
84
+ ## Команда в чаті
85
+
86
+ `/n-mdc-check` — застосуй цей workflow до правила з поточного контексту або до `<id>`, який вкаже користувач.