@nitra/cursor 1.8.13 → 1.8.18

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/js-lint.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Перевірка JavaScript коду
3
3
  alwaysApply: true
4
- version: '1.8'
4
+ version: '1.9'
5
5
  ---
6
6
 
7
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 не додавай без потреби монорепо.
@@ -38,7 +38,7 @@ version: '1.8'
38
38
  "exitCode": 1,
39
39
  "reporters": ["console"],
40
40
  "minLines": 25,
41
- "ignore": []
41
+ "ignore": ["**/dist/**",]
42
42
  }
43
43
  ```
44
44
 
@@ -132,9 +132,7 @@ export default [
132
132
 
133
133
  ## Тести
134
134
 
135
- Проекту повинен бути покритий unit тестами за допомогою Bun test.
136
-
137
- **Код:** синтаксис Node **24+**, **top level await**.
135
+ Проєкт має бути покритий unit-тестами (**Bun test**). Код: синтаксис Node **24+**, **top level await** (узгоджено з `engines.node` у `package.json`).
138
136
 
139
137
  ## Перевірка
140
138
 
package/mdc/k8s.mdc CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: K8s YAML — $schema (yaml-language-server); lint-k8s (kubeconform, kubescape); check-k8s
3
- version: '1.12'
3
+ version: '1.13'
4
4
  globs: "**/k8s/**/*.{yaml,yml}"
5
5
  alwaysApply: false
6
6
  ---
@@ -115,9 +115,19 @@ jobs:
115
115
 
116
116
  Кореневий скрипт **`lint`** (див. **`n-bun.mdc`**) **обов'язково** містить **`bun run lint-k8s`**, коли в проєкті підключено правило **`k8s`**.
117
117
 
118
+ ## Deployment: `resources`
119
+
120
+ Для **`kind: Deployment`** у кожному контейнері **`spec.template.spec.containers[]`** має бути явне поле **`resources`**. Якщо ліміти та requests ще не задані, додай порожній об'єкт:
121
+
122
+ ```yaml
123
+ resources: {}
124
+ ```
125
+
126
+ Так маніфест явно резервує місце під **`requests` / `limits`** і уникає випадкового пропуску секції. **`check k8s`** перевіряє це для кожного YAML-документа **`Deployment`** у файлах під **`k8s`**.
127
+
118
128
  ## Перевірка
119
129
 
120
- **`npx @nitra/cursor check k8s`** — узгодженість першого рядка (`$schema`), один рядок `# yaml-language-server` на файл, правило для `.yml`, відповідність URL першому YAML-документу (до `---`) за логікою нижче. Якщо під `k8s` немає yaml/yml — перевірку пропущено. Синтаксис YAML і зміст маніфесту скрипт не перевіряє — вручну.
130
+ **`npx @nitra/cursor check k8s`** — узгодженість першого рядка (`$schema`), один рядок `# yaml-language-server` на файл, правило для `.yml`, відповідність URL першому YAML-документу (до `---`) за логікою нижче; для кожного документа **`Deployment`** у файлі — наявність **`containers[].resources`** (див. розділ **Deployment: `resources`**). Якщо під `k8s` немає yaml/yml — перевірку пропущено. Інший зміст маніфесту вручну / **`lint-k8s`**.
121
131
 
122
132
  Після змін у маніфестах: **`bun run lint-k8s`** (kubeconform + kubescape; обов'язково для проєктів з правилом **`k8s`** у **`.n-cursor.json`**).
123
133
 
@@ -127,11 +137,13 @@ jobs:
127
137
 
128
138
  - Обхід з пропуском `node_modules`, `.git`, `dist`, `coverage`, `.turbo`, `.next`.
129
139
  - **`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`).
140
+ - У кожному YAML-документі з **`kind: Deployment`** — парсинг через **`yaml`**: у кожного елемента **`spec.template.spec.containers[]`** має існувати ключ **`resources`** зі значенням-об'єктом (допускається порожній **`{}`**).
130
141
 
131
142
  ## Коли застосовувати (агентам)
132
143
 
133
144
  - Зміни в k8s YAML — після правок **`check k8s`**.
134
145
  - Якщо перший рядок уже коректний і URL відповідає `apiVersion` / `kind` — не дублюй; змінився ресурс — онови лише `$schema`.
146
+ - У **`Deployment`** без поля **`resources`** у контейнері — додай **`resources: {}`** (див. розділ **Deployment: `resources`**).
135
147
 
136
148
  ## Визначення схеми YAML (канон)
137
149
 
@@ -1,11 +1,8 @@
1
1
  ---
2
2
  description: Правила nginx для статичних файлів
3
- version: '1.1'
3
+ version: '1.2'
4
4
  ---
5
5
 
6
- # Якщо в проекті є файл default.tpl.conf або default.conf.template
7
-
8
- Якщо файл називається default.tpl.conf його потрібно перейменувати на default.conf.template
9
6
 
10
7
  default.conf.template повинен виглядати так:
11
8
 
@@ -96,7 +93,7 @@ spec:
96
93
  port: 8080
97
94
  ```
98
95
 
99
- де $PUBLIC_PATH підставляється з ini файлу з dev середовища, а якщо для інших середовищ відрізняється то підставляється в kustomization.ya,l відповідні значення з ini середовища.
96
+ де $PUBLIC_PATH підставляється з ini файлу з dev середовища, а якщо для інших середовищ відрізняється то підставляється в kustomization.yaml відповідні значення з ini середовища.
100
97
 
101
98
  В $SERVICE_NAME повинно бути вказано ім'я сервісу, яке буде використовуватися в k8s.
102
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.13",
3
+ "version": "1.8.18",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -35,6 +35,9 @@
35
35
  "scripts": {
36
36
  "test": "bun test tests"
37
37
  },
38
+ "dependencies": {
39
+ "yaml": "^2.8.3"
40
+ },
38
41
  "engines": {
39
42
  "node": ">=24"
40
43
  }
@@ -1,14 +1,42 @@
1
1
  /**
2
2
  * Перевіряє лінт JavaScript за правилом js-lint.mdc.
3
3
  *
4
- * Flat ESLint, скрипт `lint-js` (oxlint, eslint, jscpd), `engines.node`, без prettier,
5
- * наявність `.jscpd.json` і workflow `lint-js.yml`.
4
+ * Канонічний `lint-js`, flat ESLint з getConfig і ignore для auto-imports, рекомендації VSCode,
5
+ * `.jscpd.json` (gitignore, exitCode, reporters, minLines), workflow `lint-js.yml` (checkout@v6,
6
+ * setup-bun-deps, bunx без --fix), без prettier, `engines.node` >= 24. Дубль перевірки JS у `lint.yml` —
7
+ * заборонено.
6
8
  */
7
9
  import { existsSync } from 'node:fs'
8
10
  import { readFile } from 'node:fs/promises'
9
11
 
10
12
  import { pass } from './utils/pass.mjs'
11
13
 
14
+ /** Очікуваний локальний скрипт (oxlint без bunx; eslint/jscpd через bunx). */
15
+ export const CANONICAL_LINT_JS = 'oxlint --fix && bunx eslint --fix . && bunx jscpd .'
16
+
17
+ /** Мінімальні рекомендації розширень редактора з js-lint.mdc (eslint, oxlint, GA). */
18
+ export const REQUIRED_VSCODE_EXTENSIONS = ['dbaeumer.vscode-eslint', 'github.vscode-github-actions', 'oxc.oxc-vscode']
19
+
20
+ /**
21
+ * Нормалізує рядок скрипта для порівняння (зайві пробіли).
22
+ * @param {string} s вихідний рядок скрипта `lint-js`
23
+ * @returns {string} рядок без зайвих пробілів на краях і з одиничними пробілами всередині
24
+ */
25
+ export function normalizeLintJsScript(s) {
26
+ return String(s).trim().replaceAll(/\s+/gu, ' ')
27
+ }
28
+
29
+ /**
30
+ * Чи рядок `lint-js` збігається з каноном і без `bunx oxlint`.
31
+ * @param {string} script значення `scripts.lint-js` з package.json
32
+ * @returns {boolean} true, якщо рядок канонічний
33
+ */
34
+ export function isCanonicalLintJs(script) {
35
+ const n = normalizeLintJsScript(script)
36
+ if (n.includes('bunx oxlint')) return false
37
+ return n === CANONICAL_LINT_JS
38
+ }
39
+
12
40
  /**
13
41
  * Перевіряє відповідність проєкту правилам js-lint.mdc
14
42
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
@@ -20,12 +48,34 @@ export async function check() {
20
48
  exitCode = 1
21
49
  }
22
50
 
51
+ let eslintPath = ''
23
52
  if (existsSync('eslint.config.js')) {
53
+ eslintPath = 'eslint.config.js'
24
54
  pass('eslint.config.js існує')
25
55
  } else if (existsSync('eslint.config.mjs')) {
56
+ eslintPath = 'eslint.config.mjs'
26
57
  pass('eslint.config.mjs існує')
27
58
  } else {
28
- fail('Відсутній eslint.config.js — створи його з getConfig від @nitra/eslint-config')
59
+ fail('Відсутній eslint.config.js або eslint.config.mjs flat config з getConfig (js-lint.mdc)')
60
+ }
61
+
62
+ if (eslintPath) {
63
+ const eslintRaw = await readFile(eslintPath, 'utf8')
64
+ if (eslintRaw.includes('getConfig')) {
65
+ pass(`${eslintPath}: містить getConfig`)
66
+ } else {
67
+ fail(`${eslintPath}: потрібен виклик getConfig (js-lint.mdc)`)
68
+ }
69
+ if (eslintRaw.includes('@nitra/eslint-config')) {
70
+ pass(`${eslintPath}: імпорт @nitra/eslint-config`)
71
+ } else {
72
+ fail(`${eslintPath}: імпортуй getConfig з @nitra/eslint-config`)
73
+ }
74
+ if (eslintRaw.includes('**/auto-imports.d.ts')) {
75
+ pass(`${eslintPath}: ignores містить **/auto-imports.d.ts`)
76
+ } else {
77
+ fail(`${eslintPath}: додай у ignores запис **/auto-imports.d.ts (js-lint.mdc)`)
78
+ }
29
79
  }
30
80
 
31
81
  if (existsSync('package.json')) {
@@ -34,38 +84,25 @@ export async function check() {
34
84
  if (pkg.scripts?.['lint-js']) {
35
85
  pass('package.json містить скрипт lint-js')
36
86
  const lintJs = String(pkg.scripts['lint-js'])
37
- if (lintJs.includes('jscpd')) {
38
- pass('lint-js містить jscpd')
39
- } else {
40
- fail('lint-js має викликати jscpd — додай "&& bunx jscpd ." у кінець скрипта')
41
- }
42
- if (lintJs.includes('bunx eslint')) {
43
- pass('lint-js викликає bunx eslint')
44
- } else {
45
- fail('lint-js має містити bunx eslint (n-js-lint.mdc)')
46
- }
47
- if (lintJs.includes('bunx jscpd')) {
48
- pass('lint-js викликає bunx jscpd')
49
- } else {
50
- fail('lint-js має містити bunx jscpd (n-js-lint.mdc)')
51
- }
52
- if (lintJs.includes('oxlint')) {
53
- pass('lint-js містить oxlint')
87
+ if (isCanonicalLintJs(lintJs)) {
88
+ pass(`lint-js збігається з каноном: ${CANONICAL_LINT_JS}`)
54
89
  } else {
55
- fail('lint-js має містити oxlint (n-js-lint.mdc)')
90
+ fail(
91
+ `lint-js має бути рівно: "${CANONICAL_LINT_JS}" (oxlint без bunx; див. js-lint.mdc / check-js-lint.mjs). Зараз: ${JSON.stringify(normalizeLintJsScript(lintJs))}`
92
+ )
56
93
  }
57
94
  } else {
58
- fail('package.json не містить скрипт "lint-js" — додай: "oxlint --fix && bunx eslint --fix . && bunx jscpd ."')
95
+ fail(`package.json не містить скрипт "lint-js" — додай: ${JSON.stringify(CANONICAL_LINT_JS)}`)
59
96
  }
60
97
 
61
98
  const allDeps = { ...pkg.dependencies, ...pkg.devDependencies }
62
99
  if (allDeps.prettier) {
63
- fail('package.json: видали залежність prettier (oxfmt замість prettier, n-js-lint.mdc)')
100
+ fail('package.json: видали залежність prettier (oxfmt замість prettier, js-lint.mdc)')
64
101
  } else {
65
102
  pass('package.json не містить prettier')
66
103
  }
67
104
  if (allDeps['@nitra/prettier-config']) {
68
- fail('package.json: видали @nitra/prettier-config (n-js-lint.mdc)')
105
+ fail('package.json: видали @nitra/prettier-config (js-lint.mdc)')
69
106
  } else {
70
107
  pass('package.json не містить @nitra/prettier-config')
71
108
  }
@@ -78,7 +115,7 @@ export async function check() {
78
115
 
79
116
  const nodeEngine = pkg.engines?.node
80
117
  if (nodeEngine) {
81
- const match = nodeEngine.match(/(\d+)/)
118
+ const match = nodeEngine.match(/(\d+)/u)
82
119
  if (match && Number(match[1]) >= 24) {
83
120
  pass(`engines.node: "${nodeEngine}"`)
84
121
  } else {
@@ -89,26 +126,68 @@ export async function check() {
89
126
  }
90
127
  }
91
128
 
129
+ if (existsSync('.vscode/extensions.json')) {
130
+ let ext
131
+ try {
132
+ ext = JSON.parse(await readFile('.vscode/extensions.json', 'utf8'))
133
+ } catch {
134
+ fail('.vscode/extensions.json не є валідним JSON')
135
+ ext = null
136
+ }
137
+ if (ext) {
138
+ const rec = ext.recommendations
139
+ if (Array.isArray(rec)) {
140
+ const missing = REQUIRED_VSCODE_EXTENSIONS.filter(id => !rec.includes(id))
141
+ if (missing.length > 0) {
142
+ fail(`.vscode/extensions.json: додай у recommendations: ${missing.join(', ')} (мінімум для js-lint.mdc)`)
143
+ } else {
144
+ pass('.vscode/extensions.json: є рекомендації oxlint, eslint і GitHub Actions')
145
+ }
146
+ } else {
147
+ fail('.vscode/extensions.json: поле recommendations має бути масивом')
148
+ }
149
+ }
150
+ } else {
151
+ fail('.vscode/extensions.json не існує — додай recommendations з js-lint.mdc (див. check-js-lint.mjs)')
152
+ }
153
+
92
154
  if (existsSync('.github/workflows/lint-js.yml')) {
93
155
  const content = await readFile('.github/workflows/lint-js.yml', 'utf8')
94
156
  pass('lint-js.yml існує')
95
- if (content.includes('oxlint')) {
96
- pass('lint-js.yml містить oxlint')
97
- } else {
98
- fail('lint-js.yml не містить oxlint')
157
+ const checks = [
158
+ ['actions/checkout@v6', 'lint-js.yml: потрібен крок actions/checkout@v6 (ga.mdc)'],
159
+ ['persist-credentials: false', 'lint-js.yml: checkout з persist-credentials: false'],
160
+ ['./.github/actions/setup-bun-deps', 'lint-js.yml: потрібен uses: ./.github/actions/setup-bun-deps'],
161
+ ['bunx oxlint', 'lint-js.yml: у run має бути bunx oxlint'],
162
+ ['bunx eslint .', 'lint-js.yml: у run має бути bunx eslint . (без --fix у CI)'],
163
+ ['bunx jscpd .', 'lint-js.yml: у run має бути bunx jscpd .']
164
+ ]
165
+ for (const [needle, errMsg] of checks) {
166
+ if (content.includes(needle)) {
167
+ pass(`lint-js.yml містить: ${needle}`)
168
+ } else {
169
+ fail(errMsg)
170
+ }
99
171
  }
100
- if (content.includes('eslint')) {
101
- pass('lint-js.yml містить eslint')
102
- } else {
103
- fail('lint-js.yml не містить eslint')
172
+ if (content.includes('bunx oxlint') && /bunx\s+oxlint[^\n]*--fix/u.test(content)) {
173
+ fail('lint-js.yml: у CI не використовуй oxlint --fix (лише bunx oxlint)')
104
174
  }
105
- if (content.includes('jscpd')) {
106
- pass('lint-js.yml містить jscpd')
107
- } else {
108
- fail('lint-js.yml не містить jscpd — додай крок bunx jscpd .')
175
+ if (content.includes('eslint --fix')) {
176
+ fail('lint-js.yml: у CI не використовуй eslint --fix (лише bunx eslint .)')
109
177
  }
110
178
  } else {
111
- fail('.github/workflows/lint-js.yml не існує — створи його')
179
+ fail('.github/workflows/lint-js.yml не існує — створи його (див. check-js-lint.mjs / js-lint.mdc)')
180
+ }
181
+
182
+ if (existsSync('.github/workflows/lint.yml')) {
183
+ const lintYml = await readFile('.github/workflows/lint.yml', 'utf8')
184
+ const looksLikeJsLint =
185
+ /\bbunx\s+oxlint\b/u.test(lintYml) && /\bbunx\s+eslint\b/u.test(lintYml) && /\bjscpd\b/u.test(lintYml)
186
+ if (looksLikeJsLint) {
187
+ fail('.github/workflows/lint.yml дублює кроки lint-js.yml — залиш один workflow на лінт JS (js-lint.mdc)')
188
+ } else {
189
+ pass('.github/workflows/lint.yml не дублює oxlint/eslint/jscpd з lint-js.yml')
190
+ }
112
191
  }
113
192
 
114
193
  if (existsSync('.jscpd.json')) {
@@ -131,13 +210,25 @@ export async function check() {
131
210
  } else {
132
211
  fail('.jscpd.json має містити "exitCode": 1 (інакше CI не впаде на клонах)')
133
212
  }
213
+ const reporters = jscpdCfg.reporters
214
+ if (Array.isArray(reporters) && reporters.includes('console')) {
215
+ pass('.jscpd.json: reporters містить console')
216
+ } else {
217
+ fail('.jscpd.json має містити "reporters": ["console"] (або масив із "console")')
218
+ }
219
+ const minLines = jscpdCfg.minLines
220
+ if (typeof minLines === 'number' && minLines >= 25) {
221
+ pass(`.jscpd.json: minLines ${minLines} (>=25)`)
222
+ } else {
223
+ fail('.jscpd.json має містити "minLines" як число >= 25')
224
+ }
134
225
  }
135
226
  } else {
136
- fail('.jscpd.json не існує — створи з gitignore, exitCode та reporters згідно js-lint.mdc')
227
+ fail('.jscpd.json не існує — створи з полями згідно check js-lint')
137
228
  }
138
229
 
139
230
  for (const dup of ['.eslintrc', '.eslintrc.js', '.eslintrc.json', '.eslintrc.yml']) {
140
- if (existsSync(dup)) fail(`Знайдено застарілий конфіг ESLint: ${dup} — видали, використовуй eslint.config.js`)
231
+ if (existsSync(dup)) fail(`Знайдено застарілий конфіг ESLint: ${dup} — видали, використовуй flat config`)
141
232
  }
142
233
 
143
234
  return exitCode
@@ -5,6 +5,10 @@
5
5
  * (окрім `kustomization.yml`); URL схеми за першим документом — kustomization / yannh / datree
6
6
  * (datree за замовчуванням: GitHub Pages `https://datreeio.github.io/CRDs-catalog/…`).
7
7
  *
8
+ * Додатково: у кожному YAML-документі з **`kind: Deployment`** у кожного контейнера
9
+ * **`spec.template.spec.containers[]`** має бути ключ **`resources`** (значення — об'єкт, допускається
10
+ * порожній **`{}`**).
11
+ *
8
12
  * Явні винятки до загальної логіки yannh/datree — таблиця **`EXPLICIT_K8S_SCHEMAS`** (`Map`): ключ
9
13
  * **`apiVersion`, `kind`, `type`** (для CRD без поля `type` у маніфесті — зірочка **`*`** як третій
10
14
  * компонент). Спочатку шукається збіг за фактичним `type`, потім за **`*`**.
@@ -13,6 +17,8 @@
13
17
  import { readFile } from 'node:fs/promises'
14
18
  import { basename, relative } from 'node:path'
15
19
 
20
+ import { parseAllDocuments } from 'yaml'
21
+
16
22
  import { pass } from './utils/pass.mjs'
17
23
  import { walkDir } from './utils/walkDir.mjs'
18
24
 
@@ -199,6 +205,78 @@ function extractApiVersionAndKind(doc) {
199
205
  }
200
206
  }
201
207
 
208
+ /**
209
+ * Чи порушує маніфест вимогу **`Deployment.spec.template.spec.containers[].resources`** (див. k8s.mdc).
210
+ * @param {unknown} manifest корінь YAML-документа як об'єкт JavaScript
211
+ * @returns {string | null} текст порушення для `fail` або null, якщо перевірка не застосовується / ок
212
+ */
213
+ export function deploymentResourcesViolation(manifest) {
214
+ if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
215
+ return null
216
+ const rec = /** @type {Record<string, unknown>} */ (manifest)
217
+ if (rec.kind !== 'Deployment') return null
218
+ const spec = rec.spec
219
+ if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) return null
220
+ const template = /** @type {Record<string, unknown>} */ (spec).template
221
+ if (template === null || template === undefined || typeof template !== 'object' || Array.isArray(template))
222
+ return null
223
+ const podSpec = /** @type {Record<string, unknown>} */ (template).spec
224
+ if (podSpec === null || podSpec === undefined || typeof podSpec !== 'object' || Array.isArray(podSpec)) return null
225
+ const containers = /** @type {Record<string, unknown>} */ (podSpec).containers
226
+ if (!Array.isArray(containers)) return null
227
+
228
+ for (const [i, c] of containers.entries()) {
229
+ const label =
230
+ typeof c === 'object' && c !== null && !Array.isArray(c) && typeof c.name === 'string' && c.name !== ''
231
+ ? c.name
232
+ : `#${i + 1}`
233
+ if (c !== null && c !== undefined && typeof c === 'object' && !Array.isArray(c)) {
234
+ const cont = /** @type {Record<string, unknown>} */ (c)
235
+ if (!('resources' in cont)) {
236
+ return `контейнер "${label}": відсутнє поле resources — додай resources: {} (див. k8s.mdc)`
237
+ }
238
+ const r = cont.resources
239
+ if (r === null || typeof r !== 'object' || Array.isArray(r)) {
240
+ return `контейнер "${label}": resources має бути об'єктом (наприклад порожній об'єкт у YAML: resources: {})`
241
+ }
242
+ }
243
+ }
244
+
245
+ return null
246
+ }
247
+
248
+ /**
249
+ * Парсить усі YAML-документи з тіла файлу й реєструє порушення **`Deployment.resources`**.
250
+ * @param {string} rel відносний шлях (для повідомлень)
251
+ * @param {string} body вміст після modeline
252
+ * @param {(msg: string) => void} fail реєстрація помилки
253
+ */
254
+ function validateDeploymentResourcesInK8sYaml(rel, body, fail) {
255
+ /** @type {import('yaml').Document[]} */
256
+ let docs
257
+ try {
258
+ docs = parseAllDocuments(body)
259
+ } catch (error) {
260
+ const msg = error instanceof Error ? error.message : String(error)
261
+ fail(
262
+ `${rel}: не вдалося розібрати YAML для перевірки Deployment.spec.template.spec.containers[].resources (${msg})`
263
+ )
264
+ return
265
+ }
266
+
267
+ for (const [di, doc] of docs.entries()) {
268
+ if (doc.errors.length > 0) {
269
+ fail(`${rel}: YAML (документ ${di + 1}): ${doc.errors.map(e => e.message).join('; ')}`)
270
+ } else {
271
+ const obj = doc.toJSON()
272
+ const v = deploymentResourcesViolation(obj)
273
+ if (v !== null) {
274
+ fail(`${rel}: Deployment (документ ${di + 1}): ${v}`)
275
+ }
276
+ }
277
+ }
278
+ }
279
+
202
280
  /**
203
281
  * Kind для імен файлів yannh/datree: лише літери та цифри, нижній регістр (Service → service, HTTPRoute → httproute).
204
282
  * @param {string} kind значення поля kind
@@ -318,31 +396,31 @@ async function checkK8sYamlFile(abs, root, fail, pass) {
318
396
  return
319
397
  }
320
398
 
399
+ const body = yamlBodyAfterModeline(lines)
400
+
321
401
  if (schemaUrl.startsWith('file:')) {
322
402
  pass(`${rel}: локальна схема (file:) — перевірка URL за apiVersion/kind пропущена`)
323
- return
324
- }
403
+ } else if (/^https:/iu.test(schemaUrl)) {
404
+ const doc = firstYamlDocument(body)
405
+ const { expected, reason } = expectedSchemaUrl(abs, doc)
325
406
 
326
- if (!/^https:/iu.test(schemaUrl)) {
327
- fail(`${rel}: $schema має бути https URL або file: (див. k8s.mdc)`)
328
- return
329
- }
330
-
331
- const body = yamlBodyAfterModeline(lines)
332
- const doc = firstYamlDocument(body)
333
- const { expected, reason } = expectedSchemaUrl(abs, doc)
407
+ if (expected === null) {
408
+ fail(`${rel}: ${reason}`)
409
+ return
410
+ }
334
411
 
335
- if (expected === null) {
336
- fail(`${rel}: ${reason}`)
337
- return
338
- }
412
+ if (schemaUrl !== expected) {
413
+ fail(`${rel}: $schema не відповідає правилу (${reason}). Очікується:\n ${expected}\n Зараз: ${schemaUrl}`)
414
+ return
415
+ }
339
416
 
340
- if (schemaUrl !== expected) {
341
- fail(`${rel}: $schema не відповідає правилу (${reason}). Очікується:\n ${expected}\n Зараз: ${schemaUrl}`)
417
+ pass(`${rel}: $schema узгоджено (${reason})`)
418
+ } else {
419
+ fail(`${rel}: $schema має бути https URL або file: (див. k8s.mdc)`)
342
420
  return
343
421
  }
344
422
 
345
- pass(`${rel}: $schema узгоджено (${reason})`)
423
+ validateDeploymentResourcesInK8sYaml(rel, body, fail)
346
424
  }
347
425
 
348
426
  /**
@@ -1,13 +1,263 @@
1
1
  /**
2
- * Перевіряє шаблон nginx за правилом nginx-default-tpl.mdc.
2
+ * Перевіряє nginx-шаблон і супутні файли за правилом nginx-default-tpl.mdc.
3
3
  *
4
- * Правильна назва файлу, `listen 8080`, `/healthz`, `gzip_static`, без `proxy_pass` у шаблоні,
5
- * рекомендації VSCode для nginx.
4
+ * Якщо в дереві є **default.conf.template**: канонічні директиви (порт 8080, /healthz, gzip_static,
5
+ * без proxy), поруч **\*.ini** (ключі з ini мають зустрічатися в шаблоні як **$KEY**), у будь-якому
6
+ * Dockerfile — **find** + **gzip** для каталогу `/usr/share/nginx/html` та **envsubst** з
7
+ * **default.conf.template**. Приклад **HTTPRoute** з правила — для рев’ю; автоматична перевірка
8
+ * вимкнена (різні схеми маршрутизації). Функція **`httpRouteMatchesNginxDefaultTpl`** лишається для
9
+ * тестів і майбутнього вузького застосування. VSCode: **extensions.json** та **settings.json** з
10
+ * форматером nginx і **formatOnSave**.
11
+ *
12
+ * У дереві від **cwd** усі **default.tpl.conf** стають **default.conf.template**: перейменування, або
13
+ * якщо **default.conf.template** уже є — він перезаписується вмістом **default.tpl.conf**, після чого
14
+ * **default.tpl.conf** видаляється. Якщо після міграції шаблону немає — перевірка пропускається (0).
6
15
  */
7
16
  import { existsSync } from 'node:fs'
8
- import { readFile } from 'node:fs/promises'
17
+ import { readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
18
+ import { basename, dirname, join, relative } from 'node:path'
9
19
 
20
+ import { findDockerfilePaths } from './check-docker.mjs'
10
21
  import { pass } from './utils/pass.mjs'
22
+ import { walkDir } from './utils/walkDir.mjs'
23
+
24
+ /**
25
+ * Збирає абсолютні шляхи до **default.conf.template** у репозиторії; шлях `tests/fixtures` не обходиться як проєктний шаблон.
26
+ * @param {string} root корінь cwd
27
+ * @returns {Promise<string[]>} відсортовані абсолютні шляхи до шаблонів
28
+ */
29
+ export async function findDefaultConfTemplatePaths(root) {
30
+ /** @type {string[]} */
31
+ const out = []
32
+ await walkDir(root, p => {
33
+ if (basename(p) !== 'default.conf.template') return
34
+ const rel = relative(root, p).replaceAll('\\', '/')
35
+ if (rel.includes('tests/fixtures/')) return
36
+ out.push(p)
37
+ })
38
+ return out.toSorted((a, b) => a.localeCompare(b))
39
+ }
40
+
41
+ /**
42
+ * Знаходить у дереві від `root` усі **default.tpl.conf**. Якщо поруч немає **default.conf.template** —
43
+ * перейменовує файл; якщо є — перезаписує **default.conf.template** вмістом **default.tpl.conf** і видаляє **default.tpl.conf**.
44
+ * @param {string} root корінь обходу (зазвичай cwd репозиторію)
45
+ * @returns {Promise<{ renamed: string[], overwritten: string[] }>} відносні шляхи до обробленого **default.tpl.conf** (для звіту)
46
+ */
47
+ export async function migrateDefaultTplConfFiles(root) {
48
+ /** @type {string[]} */
49
+ const oldPaths = []
50
+ await walkDir(root, p => {
51
+ if (basename(p) === 'default.tpl.conf') oldPaths.push(p)
52
+ })
53
+ oldPaths.sort((a, b) => a.localeCompare(b))
54
+
55
+ /** @type {string[]} */
56
+ const renamed = []
57
+ /** @type {string[]} */
58
+ const overwritten = []
59
+
60
+ for (const oldPath of oldPaths) {
61
+ const newPath = join(dirname(oldPath), 'default.conf.template')
62
+ const relOld = relative(root, oldPath).replaceAll('\\', '/') || oldPath.replaceAll('\\', '/')
63
+ if (existsSync(newPath)) {
64
+ const body = await readFile(oldPath, 'utf8')
65
+ await writeFile(newPath, body, 'utf8')
66
+ await unlink(oldPath)
67
+ overwritten.push(relOld)
68
+ } else {
69
+ await rename(oldPath, newPath)
70
+ renamed.push(relOld)
71
+ }
72
+ }
73
+
74
+ return { renamed, overwritten }
75
+ }
76
+
77
+ /**
78
+ * Імена змінних з ini (рядки KEY=value, без коментарів і порожніх).
79
+ * @param {string} iniText вміст *.ini
80
+ * @returns {string[]} імена змінних у порядку появи
81
+ */
82
+ export function parseIniVariableNames(iniText) {
83
+ /** @type {string[]} */
84
+ const keys = []
85
+ for (const line of iniText.split(/\r?\n/u)) {
86
+ const t = line.trim()
87
+ if (t !== '' && !t.startsWith('#') && !t.startsWith(';')) {
88
+ const m = t.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=/u)
89
+ if (m) keys.push(m[1])
90
+ }
91
+ }
92
+ return keys
93
+ }
94
+
95
+ /**
96
+ * Перевіряє вміст **default.conf.template** на відповідність канону з nginx-default-tpl.mdc.
97
+ * @param {string} content текст шаблону
98
+ * @returns {string | null} перше порушення або null
99
+ */
100
+ export function nginxTemplateViolations(content) {
101
+ /** @type {{ msg: string, ok: (c: string) => boolean }[]} */
102
+ const rules = [
103
+ { msg: 'відсутнє server_tokens off', ok: c => c.includes('server_tokens off') },
104
+ { msg: 'відсутнє port_in_redirect off', ok: c => c.includes('port_in_redirect off') },
105
+ { msg: 'відсутнє client_max_body_size 0', ok: c => c.includes('client_max_body_size 0') },
106
+ { msg: 'відсутнє client_body_buffer_size 512M', ok: c => c.includes('client_body_buffer_size 512M') },
107
+ { msg: 'відсутнє listen 8080', ok: c => c.includes('listen 8080') },
108
+ { msg: 'відсутнє server_name _', ok: c => c.includes('server_name _') },
109
+ { msg: 'відсутнє access_log off', ok: c => c.includes('access_log off') },
110
+ { msg: 'відсутнє error_log off', ok: c => c.includes('error_log off') },
111
+ { msg: 'відсутнє root /usr/share/nginx/html', ok: c => c.includes('root /usr/share/nginx/html') },
112
+ {
113
+ msg: 'location /healthz має повертати healthy (див. nginx-default-tpl.mdc)',
114
+ ok: c => c.includes('/healthz') && (c.includes('healthy') || /return\s+200/u.test(c))
115
+ },
116
+ {
117
+ msg: 'відсутній location для статики без gzip (gif|jpeg|png|ico|woff2|xlsx) з Cache-Control 31536000',
118
+ ok: c =>
119
+ c.includes('gif|jpe?g|png|ico|woff2|xlsx') &&
120
+ c.includes('31536000') &&
121
+ c.includes('alias /usr/share/nginx/html/')
122
+ },
123
+ {
124
+ msg: 'відсутній location для svg|js|css|ttf|map|xml|webmanifest|wasm з gzip_static',
125
+ ok: c => c.includes('svg|js|css|ttf|map|xml|webmanifest|wasm')
126
+ },
127
+ {
128
+ msg: 'gzip_static on має бути принаймні двічі (два location зі стисненням)',
129
+ ok: c => (c.match(/gzip_static\s+on/gu) ?? []).length >= 2
130
+ },
131
+ { msg: 'відсутнє використання $PUBLIC_PATH у location', ok: c => c.includes('$PUBLIC_PATH') },
132
+ {
133
+ msg: 'відсутні sendfile on; sendfile_max_chunk 512k; tcp_nopush on',
134
+ ok: c => c.includes('sendfile on') && c.includes('sendfile_max_chunk 512k') && c.includes('tcp_nopush on')
135
+ },
136
+ {
137
+ msg: 'відсутнє try_files $uri $uri/ /index.html =404',
138
+ ok: c => c.includes('try_files $uri $uri/ /index.html =404')
139
+ }
140
+ ]
141
+
142
+ for (const { msg, ok } of rules) {
143
+ if (!ok(content)) return msg
144
+ }
145
+
146
+ // cspell:ignore fastcgi uwsgi
147
+ const proxyLike =
148
+ /\b(proxy_pass|proxy_redirect|proxy_set_header|proxy_http_version|fastcgi_pass|grpc_pass|uwsgi_pass)\b/u
149
+ if (proxyLike.test(content)) {
150
+ return 'знайдено proxy, gRPC або інший *_pass до бекенду — прибери з шаблону, логіку винеси в HTTPRoute (k8s) (див. nginx-default-tpl.mdc)'
151
+ }
152
+
153
+ return null
154
+ }
155
+
156
+ /**
157
+ * Чи HTTPRoute відповідає патерну Exact→RequestRedirect(301, https) + PathPrefix→backendRefs:8080.
158
+ * @param {unknown} manifest корінь YAML-документа
159
+ * @returns {boolean} true, якщо структура збігається з прикладом у nginx-default-tpl.mdc
160
+ */
161
+ export function httpRouteMatchesNginxDefaultTpl(manifest) {
162
+ if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest))
163
+ return false
164
+ const m = /** @type {Record<string, unknown>} */ (manifest)
165
+ if (m.kind !== 'HTTPRoute') return false
166
+ const spec = m.spec
167
+ if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) return false
168
+ const rules = /** @type {Record<string, unknown>} */ (spec).rules
169
+ if (!Array.isArray(rules) || rules.length < 2) return false
170
+
171
+ const [first, second] = rules
172
+ if (first === null || first === undefined || typeof first !== 'object' || Array.isArray(first)) return false
173
+ if (second === null || second === undefined || typeof second !== 'object' || Array.isArray(second)) return false
174
+
175
+ const r0 = /** @type {Record<string, unknown>} */ (first)
176
+ const r1 = /** @type {Record<string, unknown>} */ (second)
177
+
178
+ const matches0 = r0.matches
179
+ const filters0 = r0.filters
180
+ const matches1 = r1.matches
181
+ const backends1 = r1.backendRefs
182
+
183
+ const hasExact =
184
+ Array.isArray(matches0) &&
185
+ matches0.some(x => {
186
+ if (x === null || x === undefined || typeof x !== 'object' || Array.isArray(x)) return false
187
+ return /** @type {Record<string, unknown>} */ (x).path?.type === 'Exact'
188
+ })
189
+
190
+ const hasRedirect =
191
+ Array.isArray(filters0) &&
192
+ filters0.some(f => {
193
+ if (f === null || f === undefined || typeof f !== 'object' || Array.isArray(f)) return false
194
+ const fr = /** @type {Record<string, unknown>} */ (f)
195
+ if (fr.type !== 'RequestRedirect') return false
196
+ const rr = fr.requestRedirect
197
+ if (rr === null || rr === undefined || typeof rr !== 'object' || Array.isArray(rr)) return false
198
+ const red = /** @type {Record<string, unknown>} */ (rr)
199
+ const code = red.statusCode
200
+ const okCode = code === 301 || code === '301'
201
+ return red.scheme === 'https' && red.path?.type === 'ReplaceFullPath' && okCode
202
+ })
203
+
204
+ const hasPrefix =
205
+ Array.isArray(matches1) &&
206
+ matches1.some(x => {
207
+ if (x === null || x === undefined || typeof x !== 'object' || Array.isArray(x)) return false
208
+ return /** @type {Record<string, unknown>} */ (x).path?.type === 'PathPrefix'
209
+ })
210
+
211
+ const has8080 =
212
+ Array.isArray(backends1) &&
213
+ backends1.some(b => {
214
+ if (b === null || b === undefined || typeof b !== 'object' || Array.isArray(b)) return false
215
+ const p = /** @type {Record<string, unknown>} */ (b).port
216
+ return p === 8080 || p === '8080'
217
+ })
218
+
219
+ return hasExact && hasRedirect && hasPrefix && has8080
220
+ }
221
+
222
+ /**
223
+ * Кожен ключ з ini має входити в шаблон як `$KEY` (envsubst).
224
+ * @param {string[]} keys імена змінних
225
+ * @param {string} template вміст default.conf.template
226
+ * @returns {string | null} повідомлення або null
227
+ */
228
+ export function iniKeysMissingInTemplate(keys, template) {
229
+ for (const k of keys) {
230
+ if (!template.includes(`$${k}`)) {
231
+ return `змінна "${k}" з *.ini не використовується в шаблоні — вилучи її з ini або додай у шаблон $${k} (див. nginx-default-tpl.mdc)`
232
+ }
233
+ }
234
+ return null
235
+ }
236
+
237
+ /**
238
+ * Чи Dockerfile містить RUN із find/gzip для статики під `/usr/share/nginx/html`.
239
+ * @param {string} dockerfileContent повний текст Dockerfile
240
+ * @returns {boolean} true, якщо знайдено типовий крок стиснення
241
+ */
242
+ function dockerfileHasGzipStaticPipeline(dockerfileContent) {
243
+ const c = dockerfileContent
244
+ return (
245
+ /\bfind\b/u.test(c) &&
246
+ c.includes('/usr/share/nginx/html') &&
247
+ /\bgzip\b/u.test(c) &&
248
+ c.includes('-k') &&
249
+ /\*\.(?:js|css)/u.test(c)
250
+ )
251
+ }
252
+
253
+ /**
254
+ * Чи Dockerfile містить envsubst для **default.conf.template**.
255
+ * @param {string} dockerfileContent повний текст Dockerfile
256
+ * @returns {boolean} true, якщо є envsubst і посилання на шаблон
257
+ */
258
+ function dockerfileHasEnvsSubstTemplate(dockerfileContent) {
259
+ return dockerfileContent.includes('envsubst') && dockerfileContent.includes('default.conf.template')
260
+ }
11
261
 
12
262
  /**
13
263
  * Перевіряє відповідність проєкту правилам nginx-default-tpl.mdc
@@ -20,56 +270,117 @@ export async function check() {
20
270
  exitCode = 1
21
271
  }
22
272
 
23
- if (existsSync('default.tpl.conf')) {
24
- fail('default.tpl.conf існує — перейменуй на default.conf.template')
273
+ const root = process.cwd()
274
+
275
+ const { renamed: tplRenamed, overwritten: tplOverwritten } = await migrateDefaultTplConfFiles(root)
276
+ for (const rel of tplRenamed) {
277
+ pass(`Перейменовано default.tpl.conf → default.conf.template: ${rel}`)
278
+ }
279
+ for (const rel of tplOverwritten) {
280
+ pass(`Перезаписано default.conf.template змістом default.tpl.conf: ${rel}`)
25
281
  }
26
282
 
27
- const tplLocations = ['default.conf.template', 'nginx/default.conf.template', 'docker/default.conf.template']
28
- const found = tplLocations.find(f => existsSync(f))
283
+ const templates = await findDefaultConfTemplatePaths(root)
284
+
285
+ if (templates.length === 0) {
286
+ pass('Немає default.conf.template — перевірку nginx-default-tpl пропущено')
287
+ return 0
288
+ }
29
289
 
30
- if (found) {
31
- pass(`${found} існує`)
32
- const content = await readFile(found, 'utf8')
290
+ pass(`Знайдено default.conf.template: ${templates.length}`)
33
291
 
34
- if (content.includes('listen 8080')) {
35
- pass('Nginx слухає порт 8080')
292
+ for (const abs of templates) {
293
+ const rel = relative(root, abs) || abs
294
+ const content = await readFile(abs, 'utf8')
295
+ const v = nginxTemplateViolations(content)
296
+ if (v) {
297
+ fail(`${rel}: ${v}`)
36
298
  } else {
37
- fail(`${found}: має містити listen 8080`)
299
+ pass(`${rel}: структура шаблону узгоджена з nginx-default-tpl.mdc`)
38
300
  }
39
301
 
40
- if (content.includes('/healthz')) {
41
- pass('Є location /healthz')
302
+ const dir = dirname(abs)
303
+ let iniNames = []
304
+ try {
305
+ const dirEntries = await readdir(dir)
306
+ iniNames = dirEntries.filter(n => n.endsWith('.ini'))
307
+ } catch {
308
+ iniNames = []
309
+ }
310
+ if (iniNames.length === 0) {
311
+ fail(`${rel}: поруч немає жодного *.ini — додай values-*.ini для середовищ (див. nginx-default-tpl.mdc)`)
42
312
  } else {
43
- fail(`${found}: відсутній location /healthz`)
313
+ pass(`${rel}: поруч є *.ini (${iniNames.length})`)
44
314
  }
45
315
 
46
- if (content.includes('gzip_static on')) {
47
- pass('gzip_static увімкнено')
48
- } else {
49
- fail(`${found}: має містити gzip_static on`)
316
+ for (const iniName of iniNames) {
317
+ const iniPath = `${dir}/${iniName}`
318
+ const iniRel = relative(root, iniPath) || iniPath
319
+ let iniRaw
320
+ try {
321
+ iniRaw = await readFile(iniPath, 'utf8')
322
+ } catch (error) {
323
+ fail(`${iniRel}: не вдалося прочитати (${error instanceof Error ? error.message : String(error)})`)
324
+ iniRaw = null
325
+ }
326
+ if (iniRaw !== null) {
327
+ const keys = parseIniVariableNames(iniRaw)
328
+ const miss = iniKeysMissingInTemplate(keys, content)
329
+ if (miss) {
330
+ fail(`${iniRel}: ${miss}`)
331
+ }
332
+ }
50
333
  }
334
+ }
51
335
 
52
- if (content.includes('proxy_pass')) {
53
- fail(`${found} містить proxy_pass — перенеси проксі-логіку до HTTPRoute в k8s`)
336
+ const dockerPaths = await findDockerfilePaths(root)
337
+ if (dockerPaths.length === 0) {
338
+ fail(
339
+ 'Є default.conf.template, але немає Dockerfile / Containerfile — додай gzip для статики та envsubst (див. nginx-default-tpl.mdc)'
340
+ )
341
+ } else {
342
+ const bodies = await Promise.all(dockerPaths.map(p => readFile(p, 'utf8')))
343
+ const gzipOk = bodies.some(body => dockerfileHasGzipStaticPipeline(body))
344
+ const envOk = bodies.some(body => dockerfileHasEnvsSubstTemplate(body))
345
+ if (gzipOk) {
346
+ pass('Dockerfile: знайдено крок стиснення статики (find + gzip -k)')
347
+ } else {
348
+ fail('Dockerfile: потрібен RUN find … /usr/share/nginx/html … gzip -k (див. nginx-default-tpl.mdc)')
349
+ }
350
+ if (envOk) {
351
+ pass('Dockerfile: знайдено envsubst для default.conf.template')
352
+ } else {
353
+ fail('Dockerfile: потрібен envsubst з default.conf.template (див. nginx-default-tpl.mdc)')
54
354
  }
55
355
  }
56
356
 
57
357
  if (existsSync('.vscode/extensions.json')) {
58
- const ext = JSON.parse(await readFile('.vscode/extensions.json', 'utf8'))
358
+ const extRaw = await readFile('.vscode/extensions.json', 'utf8')
359
+ const ext = JSON.parse(extRaw)
59
360
  if (ext.recommendations?.includes('ahmadalli.vscode-nginx-conf')) {
60
361
  pass('extensions.json містить ahmadalli.vscode-nginx-conf')
61
362
  } else {
62
363
  fail('extensions.json не містить ahmadalli.vscode-nginx-conf')
63
364
  }
365
+ } else {
366
+ fail('Очікується .vscode/extensions.json з ahmadalli.vscode-nginx-conf (див. nginx-default-tpl.mdc)')
64
367
  }
65
368
 
66
369
  if (existsSync('.vscode/settings.json')) {
67
- const s = JSON.parse(await readFile('.vscode/settings.json', 'utf8'))
370
+ const settingsRaw = await readFile('.vscode/settings.json', 'utf8')
371
+ const s = JSON.parse(settingsRaw)
372
+ if (s['editor.formatOnSave'] === true) {
373
+ pass('settings.json: editor.formatOnSave увімкнено')
374
+ } else {
375
+ fail('settings.json: увімкни editor.formatOnSave: true (див. nginx-default-tpl.mdc)')
376
+ }
68
377
  if (s['[nginx]']?.['editor.defaultFormatter'] === 'ahmadalli.vscode-nginx-conf') {
69
- pass('settings.json: nginx formatter налаштовано')
378
+ pass('settings.json: [nginx] defaultFormatter налаштовано')
70
379
  } else {
71
- fail('settings.json: [nginx] defaultFormatter має бути ahmadalli.vscode-nginx-conf')
380
+ fail('settings.json: [nginx].editor.defaultFormatter має бути ahmadalli.vscode-nginx-conf')
72
381
  }
382
+ } else {
383
+ fail('Очікується .vscode/settings.json з форматером nginx і formatOnSave (див. nginx-default-tpl.mdc)')
73
384
  }
74
385
 
75
386
  return exitCode