@nitra/cursor 1.6.4 → 1.6.11

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
@@ -14,6 +14,7 @@
14
14
 
15
15
  ```json
16
16
  {
17
+ "$schema": "https://unpkg.com/@nitra/cursor/schemas/n-cursor.json",
17
18
  "rules": ["js-format", "npm-module", "text"]
18
19
  }
19
20
  ```
@@ -30,6 +31,7 @@
30
31
 
31
32
  ```json
32
33
  {
34
+ "$schema": "https://unpkg.com/@nitra/cursor/schemas/n-cursor.json",
33
35
  "version": "2.5.0",
34
36
  "rules": ["js-format", "text"]
35
37
  }
@@ -39,11 +41,15 @@
39
41
 
40
42
  ```bash
41
43
  npx @nitra/cursor
44
+ npx @nitra/cursor check
45
+ npx @nitra/cursor check bun ga
42
46
  ```
43
47
 
44
- CLI автоматично:
48
+ Команда `check` запускає programmatic перевірки з каталогу `scripts/` пакету. Якщо в корені репозиторію вже є `.n-cursor.json`, перед перевірками виконується зчитування конфігу — зокрема додається або виправляється поле `$schema`, якщо воно відсутнє або не збігається з очікуваним URL.
45
49
 
46
- 1. Знайде або створить `.n-cursor.json` у поточній директорії
50
+ CLI автоматично (команда завантаження правил без підкоманди `check`):
51
+
52
+ 1. Знайде або створить `.n-cursor.json` у поточній директорії (із полем `$schema` на JSON Schema пакету; якщо файл уже є без коректного `$schema`, поле буде додано або оновлено при зчитуванні конфігу)
47
53
  2. Створить директорію `.cursor/rules/`, якщо її ще немає
48
54
  3. Завантажить кожне з перелічених у конфігу правило з unpkg.com і збереже файли з префіксом `n-`
49
55
  4. Після оновлення файлів на диску згенерує в корені проєкту **`AGENTS.md`**: повний вміст береться з шаблону пакету `AGENTS.template.md`, а список правил у шаблоні формується з **усіх наявних файлів `*.mdc`** у `.cursor/rules/` (відсортовано за ім’ям)
package/bin/n-cursor.js CHANGED
@@ -5,12 +5,14 @@
5
5
  *
6
6
  * Використання:
7
7
  * `npx \@nitra/cursor` — завантажити cursor-правила
8
- * `npx \@nitra/cursor check` — перевірити правила, перелічені в AGENTS.md (якщо є check-*.mjs)
8
+ * `npx \@nitra/cursor check` — перевірити правила, перелічені в AGENTS.md (якщо є check-*.mjs);
9
+ * якщо в корені вже є `.n-cursor.json`, спочатку зчитується конфіг і за потреби дописується `$schema`
9
10
  * `npx \@nitra/cursor check bun` — перевірити лише вказані правила (ігнорує AGENTS.md)
10
11
  *
11
12
  * Якщо у корені репозиторію немає .n-cursor.json, спочатку перейменовується за наявності nitra-cursor.json;
12
13
  * у `.cursor/rules` файли `nitra-*.mdc` перейменовуються на `n-*.mdc`; інакше конфіг створюється автоматично
13
- * з усіма правилами з каталогу mdc пакету (їх можна відредагувати після створення).
14
+ * з усіма правилами з каталогу mdc пакету (їх можна відредагувати після створення). У файлі завжди має бути
15
+ * поле `$schema` з посиланням на JSON Schema пакету; при зчитуванні конфігу воно додається або виправляється на диску, якщо відсутнє або некоректне.
14
16
  *
15
17
  * Файл AGENTS.md у корені: щоразу повністю перезаписується змістом з AGENTS.template.md
16
18
  * пакету; список правил у шаблоні будується з файлів *.mdc у .cursor/rules поточного проєкту.
@@ -33,6 +35,8 @@ import { fileURLToPath } from 'node:url'
33
35
  const PACKAGE_NAME = '@nitra/cursor'
34
36
  const UNPKG_BASE = 'https://unpkg.com'
35
37
  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`
36
40
  const AGENTS_FILE = 'AGENTS.md'
37
41
  const AGENTS_TEMPLATE_FILE = 'AGENTS.template.md'
38
42
  const RULES_DIR = '.cursor/rules'
@@ -144,7 +148,7 @@ async function migrateLegacyConfigIfNeeded() {
144
148
 
145
149
  /**
146
150
  * Зчитує конфіг .n-cursor.json з поточної директорії
147
- * @returns {Promise<{rules: string[], skills: string[], version?: string}>} rules, skills (id без префікса n-), опційно version; при відсутності файлу створює дефолтний конфіг
151
+ * @returns {Promise<{ $schema: string, rules: string[], skills: string[], version?: string } & Record<string, unknown>>} rules, skills (id без префікса n-), опційно version; при відсутності файлу створює дефолтний конфіг
148
152
  */
149
153
  async function readConfig() {
150
154
  await migrateLegacyConfigIfNeeded()
@@ -152,7 +156,7 @@ async function readConfig() {
152
156
  if (!existsSync(configPath)) {
153
157
  const rules = await discoverBundledRuleNames()
154
158
  const skills = await discoverBundledSkillNames()
155
- const defaultConfig = { rules, skills }
159
+ const defaultConfig = { $schema: CONFIG_SCHEMA_URL, rules, skills }
156
160
  await writeFile(configPath, `${JSON.stringify(defaultConfig, null, 2)}\n`, 'utf8')
157
161
  console.log(
158
162
  `📝 Створено ${CONFIG_FILE} з усіма правилами (${rules.length}) і skills (${skills.length}) з пакету. За потреби відредагуйте списки.\n`
@@ -175,6 +179,15 @@ async function readConfig() {
175
179
  }
176
180
  config.skills = await discoverBundledSkillNames()
177
181
  }
182
+
183
+ if (config.$schema !== CONFIG_SCHEMA_URL) {
184
+ const { $schema: _omit, ...rest } = config
185
+ const normalized = { $schema: CONFIG_SCHEMA_URL, ...rest }
186
+ await writeFile(configPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
187
+ console.log(`📝 Оновлено поле $schema у ${CONFIG_FILE}\n`)
188
+ return normalized
189
+ }
190
+
178
191
  return config
179
192
  }
180
193
 
@@ -546,6 +559,17 @@ async function runChecks(requestedRules) {
546
559
  throw new Error('No check scripts found')
547
560
  }
548
561
 
562
+ const root = cwd()
563
+ const legacyConfigPath = join(root, 'nitra-cursor.json')
564
+ if (existsSync(join(root, CONFIG_FILE)) || existsSync(legacyConfigPath)) {
565
+ try {
566
+ await readConfig()
567
+ } catch (error) {
568
+ console.error(`❌ ${error.message}`)
569
+ throw error
570
+ }
571
+ }
572
+
549
573
  let rulesToCheck
550
574
  if (requestedRules.length > 0) {
551
575
  rulesToCheck = requestedRules
package/mdc/bun.mdc CHANGED
@@ -81,3 +81,7 @@ FROM oven/bun:alpine AS build-env
81
81
  ## Перевірка
82
82
 
83
83
  `npx @nitra/cursor check bun`
84
+
85
+ ## lint
86
+
87
+ Якщо в кореневому @package.json існують скрипти з префіксом `lint-`, обов'язково створюй `lint` скрипт, який буде запускати всі ці скрипти з лінт-префіксом.
package/mdc/docker.mdc ADDED
@@ -0,0 +1,39 @@
1
+ ---
2
+ description: Dockerfile — лінт через hadolint (локально або Docker); перевірка check-docker
3
+ version: '1.0'
4
+ globs: "**/Dockerfile*"
5
+ alwaysApply: false
6
+ ---
7
+
8
+ # Docker — hadolint
9
+
10
+ [hadolint](https://github.com/hadolint/hadolint) перевіряє Dockerfile на типові помилки та рекомендації (`FROM`, `RUN`, `COPY`, shell form тощо).
11
+
12
+ ## Область
13
+
14
+ - Усі файли з іменем **`Dockerfile`** або **`Dockerfile.*`** (наприклад `Dockerfile.prod`) у репозиторії, крім ігнорованих каталогів (`node_modules`, `.git`, `dist`, …) — як у **`check-docker.mjs`**.
15
+ - Також скрипт перевірки обробляє **`Containerfile`** та **`Containerfile.*`** (Podman / альтернативні імена), навіть якщо glob правила спрацьовує переважно на `Dockerfile*`.
16
+
17
+ ## Запуск
18
+
19
+ 1. **`npx @nitra/cursor check docker`** — те саме, що рекомендовано після змін у Dockerfile.
20
+ 2. Спочатку викликається бінарник **`hadolint`** з `PATH`; якщо його немає — **`docker run`** з образом **`hadolint/hadolint:v2.12.0`** (тег узгоджуй з **`HADOLINT_IMAGE`** у `npm/scripts/check-docker.mjs`).
21
+ 3. Кореневий **`.hadolint.yaml`**: вимкнення правил, trusted registries — [документація](https://github.com/hadolint/hadolint#configure).
22
+
23
+ Якщо в репозиторії немає жодного відповідного файлу — перевірка пропускається (exit 0).
24
+
25
+ ## Агентам
26
+
27
+ - Після правок у Dockerfile проганяй **`check docker`**.
28
+ - Винятки: **`# hadolint ignore=DL3008`** (або інший код) у Dockerfile або правила в **`.hadolint.yaml`**.
29
+ - Образи на базі Bun — див. **`n-bun.mdc`**.
30
+
31
+ ## Редактор
32
+
33
+ Розширення VS Code / Cursor: **`exiasr.hadolint`** (потрібен бінарник hadolint) — за потреби в **`.vscode/extensions.json`**.
34
+
35
+ ## Перевірка
36
+
37
+ `npx @nitra/cursor check docker`
38
+
39
+ Kubernetes YAML і `$schema` у `k8s/` — окреме правило **`k8s.mdc`**, **`check k8s`**.
package/mdc/ga.mdc CHANGED
@@ -33,6 +33,7 @@ jobs:
33
33
  save_min_runs_number: 0
34
34
 
35
35
  ```
36
+
36
37
  Повинен бути файл .github/workflows/clean-merged-branch.yml, зі змістом:
37
38
 
38
39
  ```yaml
package/mdc/js-lint.mdc CHANGED
@@ -29,6 +29,7 @@ version: '1.7'
29
29
  }
30
30
  }
31
31
  ```
32
+
32
33
  У корені проєкту має бути `.jscpd.json`. Мінімум: увімкнути облік `.gitignore`, ненульовий код виходу при знаходженні клонів, консольний звіт. За потреби додай `ignore` (дзеркальні каталоги, шаблони) та `minLines`, щоб відсікти дрібні збіги:
33
34
 
34
35
  ```json title=".jscpd.json"
@@ -125,6 +126,7 @@ export default [
125
126
  ```
126
127
 
127
128
  У монорепо пакети з Vite (frontend) вкажи в секції `vue`, решту — у секції `node` у виклику `getConfig`.
129
+
128
130
  ## Додаткові js правила
129
131
 
130
132
  Завжди додавай до package.json що підтримується 24+ версія node:
@@ -134,6 +136,7 @@ export default [
134
136
  "node": ">=24"
135
137
  }
136
138
  ```
139
+
137
140
  **Код:** синтаксис Node **24+**, **top level await**.
138
141
 
139
142
  ## Перевірка
package/mdc/k8s.mdc ADDED
@@ -0,0 +1,59 @@
1
+ ---
2
+ description: K8s YAML — перший рядок yaml-language-server $schema; перевірка check-k8s за apiVersion/kind
3
+ version: '1.3'
4
+ globs: "**/k8s/**/*.{yaml,yml}"
5
+ alwaysApply: false
6
+ ---
7
+
8
+ # Kubernetes YAML у шляхах з `k8s`
9
+
10
+ Для кожного файлу `*.yaml` або `*.yml`, у шляху якого є сегмент директорії **`k8s`** (наприклад `site/k8s/base/deployment.yaml`), **перший рядок** — коментар-директива для [YAML Language Server](https://github.com/redhat-developer/yaml-language-server):
11
+
12
+ ```yaml
13
+ # yaml-language-server: $schema=https://...
14
+ ```
15
+
16
+ Далі — вміст маніфесту. Зайвий порожній рядок між коментарем і YAML не додавай, якщо в проєкті не прийнято інше.
17
+
18
+ **Розширення:** усі маніфести — **`.yaml`**. Виняток: **`kustomization.yml`** (і `kustomization.yaml`) дозволені за іменем.
19
+
20
+ **Dockerfile / hadolint** — окреме правило **`docker.mdc`** і **`npx @nitra/cursor check docker`**.
21
+
22
+ ## Перевірка
23
+
24
+ **`npx @nitra/cursor check k8s`** — узгодженість першого рядка (`$schema`), один рядок `# yaml-language-server` на файл, правило для `.yml`, відповідність URL першому YAML-документу (до `---`) за логікою нижче. Якщо під `k8s` немає yaml/yml — перевірку пропущено. Синтаксис YAML і зміст маніфесту скрипт не перевіряє — вручну.
25
+
26
+ ## Що закодовано в `check-k8s.mjs`
27
+
28
+ При зміні правил синхронно оновлюй **`YANNH_PIN`**, **`YANNH_GROUPS`**.
29
+
30
+ - Обхід з пропуском `node_modules`, `.git`, `dist`, `coverage`, `.turbo`, `.next`.
31
+ - **`file:`** у `$schema` — URL до apiVersion/kind не звіряється; **`https:`** — kustomization за іменем файлу → Schema Store; `v1` → yannh; група з `YANNH_GROUPS` → yannh; інакше → datree.
32
+
33
+ ## Коли застосовувати (агентам)
34
+
35
+ - Зміни в k8s YAML — після правок **`check k8s`**.
36
+ - Якщо перший рядок уже коректний і URL відповідає `apiVersion` / `kind` — не дублюй; змінився ресурс — онови лише `$schema`.
37
+
38
+ ## Визначення схеми YAML (канон)
39
+
40
+ Орієнтир — **перший документ** (до наступного `---`).
41
+
42
+ 1. **Ім’я** `kustomization.yaml` або `kustomization.yml` → `https://json.schemastore.org/kustomization.json`.
43
+ 2. **`apiVersion: v1`** → yannh, PIN **`v1.29.1-standalone-strict`**:
44
+ `https://raw.githubusercontent.com/yannh/kubernetes-json-schema/<PIN>/<kind>-v1.json`
45
+ `<kind>`: літери в нижньому регістрі без роздільників між CamelCase (наприклад `Service` → `service`).
46
+ 3. **`apiVersion: group/version`** і **group** у **`YANNH_GROUPS`** у скрипті → yannh:
47
+ `https://raw.githubusercontent.com/yannh/kubernetes-json-schema/<PIN>/<kind>-<group-з-крапками-як-дефіси>-<version>.json`
48
+ Приклади: `apps/v1` + `Deployment` → `deployment-apps-v1.json`; `networking.k8s.io/v1` + `Ingress` → `ingress-networking-k8s-io-v1.json`.
49
+ 4. **Інакше** (CRD тощо) → [datreeio/CRDs-catalog](https://github.com/datreeio/CRDs-catalog):
50
+ `https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/CRDs/<group>/<kind>_<version>.json`
51
+ 5. **Немає надійного публічного URL** — не вигадуй: залиш коректний `$schema` або `file:` за узгодженням.
52
+
53
+ ## Багатодокументні YAML
54
+
55
+ Одна схема на файл; скрипт звіряє **перший** документ. Інші `kind` у тому ж файлі — розділи файли або узгодь у рев’ю.
56
+
57
+ ## Редактор
58
+
59
+ Для `$schema` у VS Code / Cursor: **Red Hat YAML** (`redhat.vscode-yaml`) — за потреби в **`.vscode/extensions.json`**.
package/mdc/text.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Обробка та перевірка текстових файлів (cspell, markdownlint-cli2, v8r, CI)
3
3
  alwaysApply: true
4
- version: '1.16'
4
+ version: '1.17'
5
5
  ---
6
6
 
7
7
  **cspell**, **markdownlint-cli2**, **[v8r](https://chris48s.github.io/v8r/)** ([Schema Store](https://www.schemastore.org/)), розширення **DavidAnson.vscode-markdownlint**, workflow **`lint-text`**.
@@ -12,7 +12,8 @@ version: '1.16'
12
12
  "dbaeumer.vscode-eslint",
13
13
  "github.vscode-github-actions",
14
14
  "oxc.oxc-vscode",
15
- "DavidAnson.vscode-markdownlint"
15
+ "DavidAnson.vscode-markdownlint",
16
+ "redhat.vscode-yaml"
16
17
  ]
17
18
  }
18
19
  ```
@@ -30,7 +31,14 @@ version: '1.16'
30
31
  }
31
32
  ```
32
33
 
33
- **v8r:** чотири виклики `(bunx v8r "<glob>" || [ $? -eq 98 ])` — exit **98**, якщо glob порожній. **`.v8rignore`** лише коли немає схеми. Враховує `.gitignore`.
34
+ **v8r:** чотири виклики `(bunx v8r "<glob>" || [ $? -eq 98 ])` — exit **98**, якщо glob порожній. Враховує `.gitignore`.
35
+
36
+ У корені проєкту має бути **`.v8rignore`**: **v8r** шукає схему в [Schema Store](https://www.schemastore.org/); для JSON без запису там перевірка завершується помилкою. Мінімум виключи **`.vscode/extensions.json`** і **`.vscode/settings.json`** (немає стабільної схеми в каталозі). Інші файли без схеми — за тією ж логікою; не розширюй ignore, щоб приховати проблеми там, де схема є.
37
+
38
+ ```text title=".v8rignore"
39
+ .vscode/extensions.json
40
+ .vscode/settings.json
41
+ ```
34
42
 
35
43
  ```json title=".markdownlint-cli2.jsonc"
36
44
  {
@@ -52,6 +60,7 @@ version: '1.16'
52
60
  У корені проєкту має бути `.cspell.json` і залежності для cspell у кореневому `package.json` (зазвичай `devDependencies`).
53
61
 
54
62
  Додай workflow `.github/workflows/lint-text.yml`:
63
+
55
64
  ```yaml title=".github/workflows/lint-text.yml"
56
65
  name: Lint Text
57
66
 
@@ -63,6 +72,7 @@ on:
63
72
  - '.cspell.json'
64
73
  - '.gitignore'
65
74
  - '.markdownlint-cli2.jsonc'
75
+ - '.v8rignore'
66
76
  - '**/*.js'
67
77
  - '**/*.ts'
68
78
  - '**/*.vue'
@@ -202,7 +212,6 @@ jobs:
202
212
 
203
213
  Для іншої мови встанови відповідний пакет `@cspell/dict-*`, додай його `cspell-ext.json` у `import` і код мови в `language`. Огляд словників: [streetsidesoftware/cspell-dicts](https://github.com/streetsidesoftware/cspell-dicts).
204
214
 
205
-
206
215
  ## Перевірка
207
216
 
208
217
  `npx @nitra/cursor check text`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.6.4",
3
+ "version": "1.6.11",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -6,6 +6,10 @@
6
6
  "type": "object",
7
7
  "additionalProperties": true,
8
8
  "properties": {
9
+ "$schema": {
10
+ "type": "string",
11
+ "description": "Посилання на JSON Schema для автодоповнення та валідації в IDE; має збігатися з $id цієї схеми (рекомендовано завжди вказувати)."
12
+ },
9
13
  "rules": {
10
14
  "type": "array",
11
15
  "description": "Ідентифікатори правил без префікса n- (відповідають файлам n-<id>.mdc).",
@@ -3,6 +3,9 @@
3
3
  *
4
4
  * Очікує наявність `bun.lock`, забороняє lockfile та артефакти yarn/pnpm, директорію `.yarn`
5
5
  * і поле `packageManager` у кореневому `package.json`.
6
+ *
7
+ * Якщо в кореневому `package.json` є скрипти з префіксом `lint-`, перевіряє наявність агрегованого
8
+ * скрипта `lint`, у якому через `bun run <ім’я>` викликаються всі такі скрипти.
6
9
  */
7
10
  import { existsSync } from 'node:fs'
8
11
  import { readFile } from 'node:fs/promises'
@@ -47,6 +50,26 @@ export async function check() {
47
50
  } else {
48
51
  pass('package.json не містить packageManager')
49
52
  }
53
+
54
+ const scripts = pkg.scripts && typeof pkg.scripts === 'object' ? pkg.scripts : {}
55
+ const lintPrefixed = Object.keys(scripts).filter(name => name.startsWith('lint-'))
56
+ if (lintPrefixed.length > 0) {
57
+ const aggregate = typeof scripts.lint === 'string' ? scripts.lint : ''
58
+ if (aggregate.trim()) {
59
+ const missing = lintPrefixed.filter(name => !aggregate.includes(`bun run ${name}`))
60
+ if (missing.length > 0) {
61
+ fail(
62
+ `Скрипт \`lint\` має викликати всі lint-* через bun run; відсутньо: ${missing.map(s => `\`${s}\``).join(', ')}`
63
+ )
64
+ } else {
65
+ pass('package.json: агрегований `lint` покриває всі `lint-*` скрипти')
66
+ }
67
+ } else {
68
+ fail(
69
+ `У package.json є скрипти ${lintPrefixed.map(s => `\`${s}\``).join(', ')}, але немає агрегованого \`lint\` — додай скрипт, який запускає їх через \`bun run\``
70
+ )
71
+ }
72
+ }
50
73
  }
51
74
 
52
75
  return exitCode
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Запускає hadolint для Dockerfile / Containerfile у всьому репозиторії (див. docker.mdc).
3
+ *
4
+ * Знаходить Dockerfile, Dockerfile.*, Containerfile, Containerfile.*; пропускає node_modules, .git
5
+ * тощо. Спочатку бінарник hadolint з PATH, інакше docker run з образом hadolint/hadolint.
6
+ * Кореневий .hadolint.yaml підхоплюється hadolint автоматично.
7
+ */
8
+ import { spawnSync } from 'node:child_process'
9
+ import { basename, relative, sep } from 'node:path'
10
+
11
+ import { pass } from './utils/pass.mjs'
12
+ import { walkDir } from './utils/walkDir.mjs'
13
+
14
+ /** Тег образу для резервного запуску (узгоджуй з docker.mdc). */
15
+ const HADOLINT_IMAGE = 'hadolint/hadolint:v2.12.0'
16
+
17
+ /**
18
+ * Чи є basename Dockerfile / Containerfile (у т.ч. Dockerfile.prod).
19
+ * @param {string} name basename шляху
20
+ * @returns {boolean} true для Dockerfile / Dockerfile.* / Containerfile / Containerfile.*
21
+ */
22
+ function isDockerfileName(name) {
23
+ const n = name.toLowerCase()
24
+ if (n === 'dockerfile' || n === 'containerfile') return true
25
+ if (n.startsWith('dockerfile.') || n.startsWith('containerfile.')) return true
26
+ return false
27
+ }
28
+
29
+ /**
30
+ * Збирає абсолютні шляхи до Dockerfile / Containerfile від кореня cwd.
31
+ * @param {string} root корінь репозиторію
32
+ * @returns {Promise<string[]>} відсортовані абсолютні шляхи
33
+ */
34
+ async function findDockerfilePaths(root) {
35
+ /** @type {string[]} */
36
+ const out = []
37
+ await walkDir(root, p => {
38
+ if (isDockerfileName(basename(p))) out.push(p)
39
+ })
40
+ return out.toSorted((a, b) => a.localeCompare(b))
41
+ }
42
+
43
+ /**
44
+ * Відносний шлях від root з прямими слешами (hadolint у контейнері).
45
+ * @param {string} root корінь
46
+ * @param {string} absPath абсолютний шлях
47
+ * @returns {string} відносний шлях з прямими слешами
48
+ */
49
+ function posixRel(root, absPath) {
50
+ return relative(root, absPath).split(sep).join('/')
51
+ }
52
+
53
+ /**
54
+ * Запуск hadolint: спочатку PATH, інакше Docker.
55
+ * @param {string} root корінь репозиторію
56
+ * @param {string} absPath абсолютний шлях до Dockerfile
57
+ * @returns {{ ok: boolean, stdout: string, stderr: string, via: string }} результат перевірки hadolint та канал запуску
58
+ */
59
+ function lintDockerfileWithHadolint(root, absPath) {
60
+ const rel = posixRel(root, absPath)
61
+ const local = spawnSync('hadolint', [rel], {
62
+ cwd: root,
63
+ encoding: 'utf8',
64
+ maxBuffer: 10 * 1024 * 1024
65
+ })
66
+ if (!local.error) {
67
+ const ok = local.status === 0
68
+ return {
69
+ ok,
70
+ stdout: local.stdout ?? '',
71
+ stderr: local.stderr ?? '',
72
+ via: 'hadolint'
73
+ }
74
+ }
75
+ if (local.error.code !== 'ENOENT') {
76
+ return {
77
+ ok: false,
78
+ stdout: '',
79
+ stderr: local.error.message,
80
+ via: 'hadolint'
81
+ }
82
+ }
83
+
84
+ const docker = spawnSync(
85
+ 'docker',
86
+ ['run', '--rm', '-v', `${root}:/workdir`, '-w', '/workdir', HADOLINT_IMAGE, rel],
87
+ {
88
+ cwd: root,
89
+ encoding: 'utf8',
90
+ maxBuffer: 10 * 1024 * 1024
91
+ }
92
+ )
93
+ if (docker.error) {
94
+ return {
95
+ ok: false,
96
+ stdout: '',
97
+ stderr:
98
+ `Не знайдено hadolint у PATH і не вдалося запустити Docker (${docker.error.message}). ` +
99
+ `Встанови hadolint (наприклад brew install hadolint) або Docker (див. docker.mdc).`,
100
+ via: 'docker'
101
+ }
102
+ }
103
+ const ok = docker.status === 0
104
+ return {
105
+ ok,
106
+ stdout: docker.stdout ?? '',
107
+ stderr: docker.stderr ?? '',
108
+ via: 'docker'
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Перевіряє Dockerfile / Containerfile через hadolint (docker.mdc).
114
+ * @returns {Promise<number>} 0 — все OK, 1 — є зауваження або помилка запуску
115
+ */
116
+ export async function check() {
117
+ let exitCode = 0
118
+ const fail = msg => {
119
+ console.log(` ❌ ${msg}`)
120
+ exitCode = 1
121
+ }
122
+
123
+ const root = process.cwd()
124
+ const files = await findDockerfilePaths(root)
125
+
126
+ if (files.length === 0) {
127
+ pass('Немає Dockerfile / Containerfile — перевірку hadolint пропущено')
128
+ return 0
129
+ }
130
+
131
+ pass(`Знайдено файлів для hadolint: ${files.length}`)
132
+
133
+ for (const abs of files) {
134
+ const rel = posixRel(root, abs) || basename(abs)
135
+ const { ok, stdout, stderr, via } = lintDockerfileWithHadolint(root, abs)
136
+ const tail = (stdout + stderr).trim()
137
+ if (ok) {
138
+ pass(`${rel} (${via})`)
139
+ } else {
140
+ fail(`${rel} (${via})${tail ? `:\n${tail}` : ''}`)
141
+ }
142
+ }
143
+
144
+ return exitCode
145
+ }
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Перевіряє Kubernetes YAML у шляхах з сегментом `k8s` (див. k8s.mdc).
3
+ *
4
+ * Перший рядок `# yaml-language-server: $schema=…`, без дублікатів, розширення `.yaml`
5
+ * (окрім `kustomization.yml`); URL схеми за першим документом — kustomization / yannh / datree.
6
+ * Dockerfile — правило docker.mdc, скрипт check-docker.mjs.
7
+ */
8
+ import { readFile } from 'node:fs/promises'
9
+ import { basename, relative } from 'node:path'
10
+
11
+ import { pass } from './utils/pass.mjs'
12
+ import { walkDir } from './utils/walkDir.mjs'
13
+
14
+ /** Версія набору схем yannh — узгоджено з k8s.mdc */
15
+ const YANNH_PIN = 'v1.29.1-standalone-strict'
16
+
17
+ const KUSTOMIZATION_SCHEMA = 'https://json.schemastore.org/kustomization.json'
18
+
19
+ const YANNH_BASE = `https://raw.githubusercontent.com/yannh/kubernetes-json-schema/${YANNH_PIN}/`
20
+
21
+ const DATREE_CRD_BASE = 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/CRDs/'
22
+
23
+ /**
24
+ * Групи API Kubernetes, для яких у перевірці очікується схема yannh (не datree CRD catalog).
25
+ * `gateway.networking.k8s.io` та інші розширення поза цим списком — datree.
26
+ */
27
+ const YANNH_GROUPS = new Set([
28
+ 'admissionregistration.k8s.io',
29
+ 'apiextensions.k8s.io',
30
+ 'apiregistration.k8s.io',
31
+ 'apps',
32
+ 'authentication.k8s.io',
33
+ 'authorization.k8s.io',
34
+ 'autoscaling',
35
+ 'batch',
36
+ 'certificates.k8s.io',
37
+ 'coordination.k8s.io',
38
+ 'discovery.k8s.io',
39
+ 'events.k8s.io',
40
+ 'flowcontrol.apiserver.k8s.io',
41
+ 'internal.apiserver.k8s.io',
42
+ 'networking.k8s.io',
43
+ 'node.k8s.io',
44
+ 'policy',
45
+ 'rbac.authorization.k8s.io',
46
+ 'resource.k8s.io',
47
+ 'scheduling.k8s.io',
48
+ 'storage.k8s.io',
49
+ 'storagemigration.k8s.io'
50
+ ])
51
+
52
+ const MODELINE_RE = /^#\s*yaml-language-server:\s*\$schema=(\S+)\s*$/
53
+
54
+ /**
55
+ * Чи містить шлях сегмент директорії `k8s` (рівно ця назва компонента).
56
+ * @param {string} filePath шлях до файлу
57
+ * @returns {boolean} true, якщо серед компонентів шляху є каталог `k8s`
58
+ */
59
+ function pathHasK8sSegment(filePath) {
60
+ const parts = filePath.split(/[/\\]/u)
61
+ return parts.includes('k8s')
62
+ }
63
+
64
+ /**
65
+ * Збирає всі yaml/yml під деревом від кореня cwd, якщо шлях містить сегмент `k8s`.
66
+ * @param {string} root корінь репозиторію (cwd)
67
+ * @returns {Promise<string[]>} відсортовані абсолютні шляхи до файлів
68
+ */
69
+ async function findK8sYamlFiles(root) {
70
+ /** @type {string[]} */
71
+ const out = []
72
+ await walkDir(root, p => {
73
+ if (!pathHasK8sSegment(p)) return
74
+ if (!/\.ya?ml$/iu.test(p)) return
75
+ out.push(p)
76
+ })
77
+ return out.toSorted((a, b) => a.localeCompare(b))
78
+ }
79
+
80
+ /**
81
+ * Прибирає BOM і ділить на рядки.
82
+ * @param {string} content вміст файлу
83
+ * @returns {string[]} рядки без BOM на початку
84
+ */
85
+ function toLines(content) {
86
+ const body = content.startsWith('\uFEFF') ? content.slice(1) : content
87
+ return body.split(/\r?\n/u)
88
+ }
89
+
90
+ /**
91
+ * Вміст після першого рядка (modeline), без провідних порожніх рядків.
92
+ * @param {string[]} lines рядки файлу
93
+ * @returns {string} тіло для парсингу першого YAML-документа
94
+ */
95
+ function yamlBodyAfterModeline(lines) {
96
+ let i = 1
97
+ while (i < lines.length && lines[i].trim() === '') i++
98
+ return lines.slice(i).join('\n')
99
+ }
100
+
101
+ /**
102
+ * Перший YAML-документ (до наступного `---` на окремому рядку).
103
+ * @param {string} body фрагмент YAML
104
+ * @returns {string} перший документ без зайвих пробілів по краях
105
+ */
106
+ function firstYamlDocument(body) {
107
+ const parts = body.split(/^---\s*$/mu)
108
+ return (parts[0] ?? body).trim()
109
+ }
110
+
111
+ /**
112
+ * Витягує `apiVersion` та `kind` з тексту документа (без повного YAML-парсера).
113
+ * @param {string} doc фрагмент YAML одного документа
114
+ * @returns {{ apiVersion?: string, kind?: string }} знайдені поля або властивості відсутні
115
+ */
116
+ function extractApiVersionAndKind(doc) {
117
+ const av = doc.match(/^\s*apiVersion:\s*(\S+)\s*$/mu)
118
+ const k = doc.match(/^\s*kind:\s*(\S+)\s*$/mu)
119
+ return {
120
+ apiVersion: av?.[1]?.replaceAll(/^["']|["']$/gu, ''),
121
+ kind: k?.[1]?.replaceAll(/^["']|["']$/gu, '')
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Kind для імен файлів yannh/datree: лише літери та цифри, нижній регістр (Service → service, HTTPRoute → httproute).
127
+ * @param {string} kind значення поля kind
128
+ * @returns {string} рядок для шаблону імені файлу схеми
129
+ */
130
+ function kindToSchemaFilePart(kind) {
131
+ return kind.replaceAll(/[^a-zA-Z0-9]/gu, '').toLowerCase()
132
+ }
133
+
134
+ /**
135
+ * Очікуваний $schema для маніфесту згідно з k8s.mdc.
136
+ * @param {string} filePath шлях до файлу (для імені kustomization)
137
+ * @param {string} doc перший YAML-документ після modeline
138
+ * @returns {{ expected: string | null, reason: string }} reason — для повідомлень про помилку
139
+ */
140
+ function expectedSchemaUrl(filePath, doc) {
141
+ const base = basename(filePath)
142
+ const baseLower = base.toLowerCase()
143
+
144
+ if (baseLower === 'kustomization.yaml' || baseLower === 'kustomization.yml') {
145
+ return { expected: KUSTOMIZATION_SCHEMA, reason: 'kustomization (ім’я файлу)' }
146
+ }
147
+
148
+ const { apiVersion, kind } = extractApiVersionAndKind(doc)
149
+ if (!apiVersion || !kind) {
150
+ return {
151
+ expected: null,
152
+ reason: 'не знайдено apiVersion/kind у першому документі (потрібні для перевірки $schema)'
153
+ }
154
+ }
155
+
156
+ if (apiVersion === 'v1') {
157
+ const k = kindToSchemaFilePart(kind)
158
+ return { expected: `${YANNH_BASE}${k}-v1.json`, reason: 'core v1 (yannh)' }
159
+ }
160
+
161
+ if (!apiVersion.includes('/')) {
162
+ return {
163
+ expected: null,
164
+ reason: `нестандартний apiVersion "${apiVersion}" — очікується v1 або group/version`
165
+ }
166
+ }
167
+
168
+ const slash = apiVersion.indexOf('/')
169
+ const group = apiVersion.slice(0, Math.max(0, slash))
170
+ const version = apiVersion.slice(slash + 1)
171
+ const kindPart = kindToSchemaFilePart(kind)
172
+ const groupDash = group.replaceAll('.', '-')
173
+
174
+ if (YANNH_GROUPS.has(group)) {
175
+ const url = `${YANNH_BASE}${kindPart}-${groupDash}-${version}.json`
176
+ return { expected: url, reason: 'вбудований API Kubernetes (yannh)' }
177
+ }
178
+
179
+ const datreeKind = kindToSchemaFilePart(kind)
180
+ const url = `${DATREE_CRD_BASE}${group}/${datreeKind}_${version}.json`
181
+ return { expected: url, reason: 'CRD / група поза yannh (datree CRDs-catalog)' }
182
+ }
183
+
184
+ /**
185
+ * Підраховує рядки з modeline $schema у файлі.
186
+ * @param {string[]} lines рядки файлу
187
+ * @returns {number} скільки рядків містять modeline `$schema`
188
+ */
189
+ function countSchemaModelines(lines) {
190
+ return lines.filter(l => /^\s*#\s*yaml-language-server:\s*\$schema=\S+/u.test(l.trim())).length
191
+ }
192
+
193
+ /**
194
+ * Перевіряє один YAML у дереві k8s (modeline, схема).
195
+ * @param {string} abs абсолютний шлях до файлу
196
+ * @param {string} root корінь репозиторію
197
+ * @param {(msg: string) => void} fail реєстрація помилки
198
+ * @param {(msg: string) => void} pass реєстрація успіху
199
+ * @returns {Promise<void>}
200
+ */
201
+ async function checkK8sYamlFile(abs, root, fail, pass) {
202
+ const rel = relative(root, abs) || abs
203
+ const base = basename(abs)
204
+ const baseLower = base.toLowerCase()
205
+
206
+ if (baseLower.endsWith('.yml') && baseLower !== 'kustomization.yml') {
207
+ fail(`${rel}: розширення .yml — перейменуй на .yaml (див. k8s.mdc)`)
208
+ return
209
+ }
210
+
211
+ let raw
212
+ try {
213
+ raw = await readFile(abs, 'utf8')
214
+ } catch (error) {
215
+ fail(`${rel}: не вдалося прочитати (${error.message})`)
216
+ return
217
+ }
218
+
219
+ const lines = toLines(raw)
220
+ if (lines.length === 0 || lines[0].trim() === '') {
221
+ fail(`${rel}: перший рядок порожній — потрібен # yaml-language-server: $schema=…`)
222
+ return
223
+ }
224
+
225
+ const m = lines[0].match(MODELINE_RE)
226
+ if (!m) {
227
+ fail(
228
+ `${rel}: перший рядок має бути коментарем # yaml-language-server: $schema=<url> (без префіксів перед #)`
229
+ )
230
+ return
231
+ }
232
+
233
+ const schemaUrl = m[1]
234
+ if (countSchemaModelines(lines) > 1) {
235
+ fail(`${rel}: кілька рядків yaml-language-server $schema — лиш один modeline на файл (див. k8s.mdc)`)
236
+ return
237
+ }
238
+
239
+ if (schemaUrl.startsWith('file:')) {
240
+ pass(`${rel}: локальна схема (file:) — перевірка URL за apiVersion/kind пропущена`)
241
+ return
242
+ }
243
+
244
+ if (!/^https:/iu.test(schemaUrl)) {
245
+ fail(`${rel}: $schema має бути https URL або file: (див. k8s.mdc)`)
246
+ return
247
+ }
248
+
249
+ const body = yamlBodyAfterModeline(lines)
250
+ const doc = firstYamlDocument(body)
251
+ const { expected, reason } = expectedSchemaUrl(abs, doc)
252
+
253
+ if (expected === null) {
254
+ fail(`${rel}: ${reason}`)
255
+ return
256
+ }
257
+
258
+ if (schemaUrl !== expected) {
259
+ fail(`${rel}: $schema не відповідає правилу (${reason}). Очікується:\n ${expected}\n Зараз: ${schemaUrl}`)
260
+ return
261
+ }
262
+
263
+ pass(`${rel}: $schema узгоджено (${reason})`)
264
+ }
265
+
266
+ /**
267
+ * Перевіряє відповідність проєкту правилам k8s.mdc.
268
+ * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
269
+ */
270
+ export async function check() {
271
+ let exitCode = 0
272
+ const fail = msg => {
273
+ console.log(` ❌ ${msg}`)
274
+ exitCode = 1
275
+ }
276
+
277
+ const root = process.cwd()
278
+ const yamlFiles = await findK8sYamlFiles(root)
279
+
280
+ if (yamlFiles.length === 0) {
281
+ pass('Немає yaml/yml під k8s — перевірку $schema пропущено')
282
+ return 0
283
+ }
284
+
285
+ pass(`YAML у k8s: ${yamlFiles.length} файл(ів)`)
286
+
287
+ for (const abs of yamlFiles) {
288
+ await checkK8sYamlFile(abs, root, fail, pass)
289
+ }
290
+
291
+ return exitCode
292
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Перевіряє текстовий стек за правилом text.mdc.
3
3
  *
4
- * cspell, markdownlint-cli2, скрипт `lint-text` з чотирма викликами v8r, workflow `lint-text.yml`,
5
- * розширення VSCode для markdownlint.
4
+ * cspell, markdownlint-cli2, скрипт `lint-text` з чотирма викликами v8r, `.v8rignore` (vscode JSON),
5
+ * workflow `lint-text.yml`, розширення VSCode для markdownlint.
6
6
  */
7
7
  import { existsSync } from 'node:fs'
8
8
  import { readFile } from 'node:fs/promises'
@@ -20,6 +20,24 @@ export async function check() {
20
20
  exitCode = 1
21
21
  }
22
22
 
23
+ const v8rIgnoreRequired = ['.vscode/extensions.json', '.vscode/settings.json']
24
+ if (existsSync('.v8rignore')) {
25
+ const raw = await readFile('.v8rignore', 'utf8')
26
+ const lines = new Set(raw
27
+ .split('\n')
28
+ .map(l => l.trim())
29
+ .filter(l => l.length > 0 && !l.startsWith('#')))
30
+ for (const path of v8rIgnoreRequired) {
31
+ if (lines.has(path)) {
32
+ pass(`.v8rignore містить ${path}`)
33
+ } else {
34
+ fail(`.v8rignore: додай рядок "${path}" (JSON без схеми в Schema Store — див. n-text.mdc)`)
35
+ }
36
+ }
37
+ } else {
38
+ fail('.v8rignore не існує — створи згідно n-text.mdc (мінімум .vscode/extensions.json і .vscode/settings.json)')
39
+ }
40
+
23
41
  if (existsSync('.vscode/extensions.json')) {
24
42
  try {
25
43
  const ext = JSON.parse(await readFile('.vscode/extensions.json', 'utf8'))
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Рекурсивний обхід каталогів для скриптів перевірки (Dockerfile, k8s YAML тощо).
3
+ *
4
+ * Обходить дерево від заданого кореня; для кожного звичайного файлу викликає переданий callback.
5
+ * Каталоги node_modules, .git, dist, coverage, .turbo, .next не заходяться. Якщо readdir для
6
+ * каталогу не вдається — тихо виходить без throw.
7
+ */
8
+ import { readdir } from 'node:fs/promises'
9
+ import { join } from 'node:path'
10
+
11
+ /**
12
+ * Рекурсивно обходить каталог, пропускає типові артефакти збірки та залежностей.
13
+ * @param {string} dir абсолютний шлях
14
+ * @param {(filePath: string) => void} onFile виклик для кожного файлу
15
+ * @returns {Promise<void>}
16
+ */
17
+ export async function walkDir(dir, onFile) {
18
+ let entries
19
+ try {
20
+ entries = await readdir(dir, { withFileTypes: true })
21
+ } catch {
22
+ return
23
+ }
24
+ for (const e of entries) {
25
+ const p = join(dir, e.name)
26
+ if (e.isDirectory()) {
27
+ const skipDir =
28
+ e.name === 'node_modules' ||
29
+ e.name === '.git' ||
30
+ e.name === 'dist' ||
31
+ e.name === 'coverage' ||
32
+ e.name === '.turbo' ||
33
+ e.name === '.next'
34
+ if (!skipDir) {
35
+ await walkDir(p, onFile)
36
+ }
37
+ } else if (e.isFile()) {
38
+ onFile(p)
39
+ }
40
+ }
41
+ }