@nitra/cursor 1.6.5 → 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/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.5",
3
+ "version": "1.6.11",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -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
+ }