@nitra/cursor 1.8.13 → 1.8.16

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/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.16",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -37,5 +37,8 @@
37
37
  },
38
38
  "engines": {
39
39
  "node": ">=24"
40
+ },
41
+ "dependencies": {
42
+ "yaml": "^2.8.3"
40
43
  }
41
44
  }
@@ -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,76 @@ function extractApiVersionAndKind(doc) {
199
205
  }
200
206
  }
201
207
 
208
+ /**
209
+ * Чи порушує маніфест вимогу **`Deployment.spec.template.spec.containers[].resources`** (див. k8s.mdc).
210
+ * @param {unknown} manifest корінь розпарсеного YAML-документа
211
+ * @returns {string | null} текст порушення для `fail` або null, якщо перевірка не застосовується / ок
212
+ */
213
+ export function deploymentResourcesViolation(manifest) {
214
+ if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest)) return null
215
+ const rec = /** @type {Record<string, unknown>} */ (manifest)
216
+ if (rec.kind !== 'Deployment') return null
217
+ const spec = rec.spec
218
+ if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) return null
219
+ const template = /** @type {Record<string, unknown>} */ (spec).template
220
+ if (template === null || template === undefined || typeof template !== 'object' || Array.isArray(template)) return null
221
+ const podSpec = /** @type {Record<string, unknown>} */ (template).spec
222
+ if (podSpec === null || podSpec === undefined || typeof podSpec !== 'object' || Array.isArray(podSpec)) return null
223
+ const containers = /** @type {Record<string, unknown>} */ (podSpec).containers
224
+ if (!Array.isArray(containers)) return null
225
+
226
+ for (const [i, c] of containers.entries()) {
227
+ const label =
228
+ typeof c === 'object' && c !== null && !Array.isArray(c) && typeof c.name === 'string' && c.name !== ''
229
+ ? c.name
230
+ : `#${i + 1}`
231
+ if (c !== null && c !== undefined && typeof c === 'object' && !Array.isArray(c)) {
232
+ const cont = /** @type {Record<string, unknown>} */ (c)
233
+ if (!('resources' in cont)) {
234
+ return `контейнер "${label}": відсутнє поле resources — додай resources: {} (див. k8s.mdc)`
235
+ }
236
+ const r = cont.resources
237
+ if (r === null || typeof r !== 'object' || Array.isArray(r)) {
238
+ return `контейнер "${label}": resources має бути об'єктом (наприклад порожній об'єкт у YAML: resources: {})`
239
+ }
240
+ }
241
+ }
242
+
243
+ return null
244
+ }
245
+
246
+ /**
247
+ * Парсить усі YAML-документи з тіла файлу й реєструє порушення **`Deployment.resources`**.
248
+ * @param {string} rel відносний шлях (для повідомлень)
249
+ * @param {string} body вміст після modeline
250
+ * @param {(msg: string) => void} fail реєстрація помилки
251
+ */
252
+ function validateDeploymentResourcesInK8sYaml(rel, body, fail) {
253
+ /** @type {import('yaml').Document[]} */
254
+ let docs
255
+ try {
256
+ docs = parseAllDocuments(body)
257
+ } catch (error) {
258
+ const msg = error instanceof Error ? error.message : String(error)
259
+ fail(
260
+ `${rel}: не вдалося розібрати YAML для перевірки Deployment.spec.template.spec.containers[].resources (${msg})`
261
+ )
262
+ return
263
+ }
264
+
265
+ for (const [di, doc] of docs.entries()) {
266
+ if (doc.errors.length > 0) {
267
+ fail(`${rel}: YAML (документ ${di + 1}): ${doc.errors.map(e => e.message).join('; ')}`)
268
+ } else {
269
+ const obj = doc.toJSON()
270
+ const v = deploymentResourcesViolation(obj)
271
+ if (v !== null) {
272
+ fail(`${rel}: Deployment (документ ${di + 1}): ${v}`)
273
+ }
274
+ }
275
+ }
276
+ }
277
+
202
278
  /**
203
279
  * Kind для імен файлів yannh/datree: лише літери та цифри, нижній регістр (Service → service, HTTPRoute → httproute).
204
280
  * @param {string} kind значення поля kind
@@ -318,31 +394,31 @@ async function checkK8sYamlFile(abs, root, fail, pass) {
318
394
  return
319
395
  }
320
396
 
397
+ const body = yamlBodyAfterModeline(lines)
398
+
321
399
  if (schemaUrl.startsWith('file:')) {
322
400
  pass(`${rel}: локальна схема (file:) — перевірка URL за apiVersion/kind пропущена`)
323
- return
324
- }
401
+ } else if (/^https:/iu.test(schemaUrl)) {
402
+ const doc = firstYamlDocument(body)
403
+ const { expected, reason } = expectedSchemaUrl(abs, doc)
325
404
 
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)
405
+ if (expected === null) {
406
+ fail(`${rel}: ${reason}`)
407
+ return
408
+ }
334
409
 
335
- if (expected === null) {
336
- fail(`${rel}: ${reason}`)
337
- return
338
- }
410
+ if (schemaUrl !== expected) {
411
+ fail(`${rel}: $schema не відповідає правилу (${reason}). Очікується:\n ${expected}\n Зараз: ${schemaUrl}`)
412
+ return
413
+ }
339
414
 
340
- if (schemaUrl !== expected) {
341
- fail(`${rel}: $schema не відповідає правилу (${reason}). Очікується:\n ${expected}\n Зараз: ${schemaUrl}`)
415
+ pass(`${rel}: $schema узгоджено (${reason})`)
416
+ } else {
417
+ fail(`${rel}: $schema має бути https URL або file: (див. k8s.mdc)`)
342
418
  return
343
419
  }
344
420
 
345
- pass(`${rel}: $schema узгоджено (${reason})`)
421
+ validateDeploymentResourcesInK8sYaml(rel, body, fail)
346
422
  }
347
423
 
348
424
  /**
@@ -1,13 +1,261 @@
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
+ const proxyLike =
147
+ /\b(proxy_pass|proxy_redirect|proxy_set_header|proxy_http_version|fastcgi_pass|grpc_pass|uwsgi_pass)\b/u
148
+ if (proxyLike.test(content)) {
149
+ return 'знайдено proxy/fastcgi/grpc — прибери з шаблону, логіку винеси в HTTPRoute (k8s) (див. nginx-default-tpl.mdc)'
150
+ }
151
+
152
+ return null
153
+ }
154
+
155
+ /**
156
+ * Чи HTTPRoute відповідає патерну Exact→RequestRedirect(301, https) + PathPrefix→backendRefs:8080.
157
+ * @param {unknown} manifest корінь YAML-документа
158
+ * @returns {boolean} true, якщо структура збігається з прикладом у nginx-default-tpl.mdc
159
+ */
160
+ export function httpRouteMatchesNginxDefaultTpl(manifest) {
161
+ if (manifest === null || manifest === undefined || typeof manifest !== 'object' || Array.isArray(manifest)) return false
162
+ const m = /** @type {Record<string, unknown>} */ (manifest)
163
+ if (m.kind !== 'HTTPRoute') return false
164
+ const spec = m.spec
165
+ if (spec === null || spec === undefined || typeof spec !== 'object' || Array.isArray(spec)) return false
166
+ const rules = /** @type {Record<string, unknown>} */ (spec).rules
167
+ if (!Array.isArray(rules) || rules.length < 2) return false
168
+
169
+ const [first, second] = rules
170
+ if (first === null || first === undefined || typeof first !== 'object' || Array.isArray(first)) return false
171
+ if (second === null || second === undefined || typeof second !== 'object' || Array.isArray(second)) return false
172
+
173
+ const r0 = /** @type {Record<string, unknown>} */ (first)
174
+ const r1 = /** @type {Record<string, unknown>} */ (second)
175
+
176
+ const matches0 = r0.matches
177
+ const filters0 = r0.filters
178
+ const matches1 = r1.matches
179
+ const backends1 = r1.backendRefs
180
+
181
+ const hasExact =
182
+ Array.isArray(matches0) &&
183
+ matches0.some(x => {
184
+ if (x === null || x === undefined || typeof x !== 'object' || Array.isArray(x)) return false
185
+ return /** @type {Record<string, unknown>} */ (x).path?.type === 'Exact'
186
+ })
187
+
188
+ const hasRedirect =
189
+ Array.isArray(filters0) &&
190
+ filters0.some(f => {
191
+ if (f === null || f === undefined || typeof f !== 'object' || Array.isArray(f)) return false
192
+ const fr = /** @type {Record<string, unknown>} */ (f)
193
+ if (fr.type !== 'RequestRedirect') return false
194
+ const rr = fr.requestRedirect
195
+ if (rr === null || rr === undefined || typeof rr !== 'object' || Array.isArray(rr)) return false
196
+ const red = /** @type {Record<string, unknown>} */ (rr)
197
+ const code = red.statusCode
198
+ const okCode = code === 301 || code === '301'
199
+ return red.scheme === 'https' && red.path?.type === 'ReplaceFullPath' && okCode
200
+ })
201
+
202
+ const hasPrefix =
203
+ Array.isArray(matches1) &&
204
+ matches1.some(x => {
205
+ if (x === null || x === undefined || typeof x !== 'object' || Array.isArray(x)) return false
206
+ return /** @type {Record<string, unknown>} */ (x).path?.type === 'PathPrefix'
207
+ })
208
+
209
+ const has8080 =
210
+ Array.isArray(backends1) &&
211
+ backends1.some(b => {
212
+ if (b === null || b === undefined || typeof b !== 'object' || Array.isArray(b)) return false
213
+ const p = /** @type {Record<string, unknown>} */ (b).port
214
+ return p === 8080 || p === '8080'
215
+ })
216
+
217
+ return hasExact && hasRedirect && hasPrefix && has8080
218
+ }
219
+
220
+ /**
221
+ * Кожен ключ з ini має входити в шаблон як `$KEY` (envsubst).
222
+ * @param {string[]} keys імена змінних
223
+ * @param {string} template вміст default.conf.template
224
+ * @returns {string | null} повідомлення або null
225
+ */
226
+ export function iniKeysMissingInTemplate(keys, template) {
227
+ for (const k of keys) {
228
+ if (!template.includes(`$${k}`)) {
229
+ return `змінна "${k}" з *.ini не використовується в шаблоні — вилучи її з ini або додай плейсхолдер $${k} (див. nginx-default-tpl.mdc)`
230
+ }
231
+ }
232
+ return null
233
+ }
234
+
235
+ /**
236
+ * Чи Dockerfile містить RUN із find/gzip для статики під `/usr/share/nginx/html`.
237
+ * @param {string} dockerfileContent повний текст Dockerfile
238
+ * @returns {boolean} true, якщо знайдено типовий крок стиснення
239
+ */
240
+ function dockerfileHasGzipStaticPipeline(dockerfileContent) {
241
+ const c = dockerfileContent
242
+ return (
243
+ /\bfind\b/u.test(c) &&
244
+ c.includes('/usr/share/nginx/html') &&
245
+ /\bgzip\b/u.test(c) &&
246
+ c.includes('-k') &&
247
+ /\*\.(?:js|css)/u.test(c)
248
+ )
249
+ }
250
+
251
+ /**
252
+ * Чи Dockerfile містить envsubst для **default.conf.template**.
253
+ * @param {string} dockerfileContent повний текст Dockerfile
254
+ * @returns {boolean} true, якщо є envsubst і посилання на шаблон
255
+ */
256
+ function dockerfileHasEnvsSubstTemplate(dockerfileContent) {
257
+ return dockerfileContent.includes('envsubst') && dockerfileContent.includes('default.conf.template')
258
+ }
11
259
 
12
260
  /**
13
261
  * Перевіряє відповідність проєкту правилам nginx-default-tpl.mdc
@@ -20,56 +268,117 @@ export async function check() {
20
268
  exitCode = 1
21
269
  }
22
270
 
23
- if (existsSync('default.tpl.conf')) {
24
- fail('default.tpl.conf існує — перейменуй на default.conf.template')
271
+ const root = process.cwd()
272
+
273
+ const { renamed: tplRenamed, overwritten: tplOverwritten } = await migrateDefaultTplConfFiles(root)
274
+ for (const rel of tplRenamed) {
275
+ pass(`Перейменовано default.tpl.conf → default.conf.template: ${rel}`)
276
+ }
277
+ for (const rel of tplOverwritten) {
278
+ pass(`Перезаписано default.conf.template змістом default.tpl.conf: ${rel}`)
25
279
  }
26
280
 
27
- const tplLocations = ['default.conf.template', 'nginx/default.conf.template', 'docker/default.conf.template']
28
- const found = tplLocations.find(f => existsSync(f))
281
+ const templates = await findDefaultConfTemplatePaths(root)
282
+
283
+ if (templates.length === 0) {
284
+ pass('Немає default.conf.template — перевірку nginx-default-tpl пропущено')
285
+ return 0
286
+ }
29
287
 
30
- if (found) {
31
- pass(`${found} існує`)
32
- const content = await readFile(found, 'utf8')
288
+ pass(`Знайдено default.conf.template: ${templates.length}`)
33
289
 
34
- if (content.includes('listen 8080')) {
35
- pass('Nginx слухає порт 8080')
290
+ for (const abs of templates) {
291
+ const rel = relative(root, abs) || abs
292
+ const content = await readFile(abs, 'utf8')
293
+ const v = nginxTemplateViolations(content)
294
+ if (v) {
295
+ fail(`${rel}: ${v}`)
36
296
  } else {
37
- fail(`${found}: має містити listen 8080`)
297
+ pass(`${rel}: структура шаблону узгоджена з nginx-default-tpl.mdc`)
38
298
  }
39
299
 
40
- if (content.includes('/healthz')) {
41
- pass('Є location /healthz')
300
+ const dir = dirname(abs)
301
+ let iniNames = []
302
+ try {
303
+ const dirEntries = await readdir(dir)
304
+ iniNames = dirEntries.filter(n => n.endsWith('.ini'))
305
+ } catch {
306
+ iniNames = []
307
+ }
308
+ if (iniNames.length === 0) {
309
+ fail(`${rel}: поруч немає жодного *.ini — додай values-*.ini для середовищ (див. nginx-default-tpl.mdc)`)
42
310
  } else {
43
- fail(`${found}: відсутній location /healthz`)
311
+ pass(`${rel}: поруч є *.ini (${iniNames.length})`)
44
312
  }
45
313
 
46
- if (content.includes('gzip_static on')) {
47
- pass('gzip_static увімкнено')
48
- } else {
49
- fail(`${found}: має містити gzip_static on`)
314
+ for (const iniName of iniNames) {
315
+ const iniPath = `${dir}/${iniName}`
316
+ const iniRel = relative(root, iniPath) || iniPath
317
+ let iniRaw
318
+ try {
319
+ iniRaw = await readFile(iniPath, 'utf8')
320
+ } catch (error) {
321
+ fail(`${iniRel}: не вдалося прочитати (${error instanceof Error ? error.message : String(error)})`)
322
+ iniRaw = null
323
+ }
324
+ if (iniRaw !== null) {
325
+ const keys = parseIniVariableNames(iniRaw)
326
+ const miss = iniKeysMissingInTemplate(keys, content)
327
+ if (miss) {
328
+ fail(`${iniRel}: ${miss}`)
329
+ }
330
+ }
50
331
  }
332
+ }
51
333
 
52
- if (content.includes('proxy_pass')) {
53
- fail(`${found} містить proxy_pass — перенеси проксі-логіку до HTTPRoute в k8s`)
334
+ const dockerPaths = await findDockerfilePaths(root)
335
+ if (dockerPaths.length === 0) {
336
+ fail(
337
+ 'Є default.conf.template, але немає Dockerfile / Containerfile — додай gzip для статики та envsubst (див. nginx-default-tpl.mdc)'
338
+ )
339
+ } else {
340
+ const bodies = await Promise.all(dockerPaths.map(p => readFile(p, 'utf8')))
341
+ const gzipOk = bodies.some(body => dockerfileHasGzipStaticPipeline(body))
342
+ const envOk = bodies.some(body => dockerfileHasEnvsSubstTemplate(body))
343
+ if (gzipOk) {
344
+ pass('Dockerfile: знайдено крок стиснення статики (find + gzip -k)')
345
+ } else {
346
+ fail('Dockerfile: потрібен RUN find … /usr/share/nginx/html … gzip -k (див. nginx-default-tpl.mdc)')
347
+ }
348
+ if (envOk) {
349
+ pass('Dockerfile: знайдено envsubst для default.conf.template')
350
+ } else {
351
+ fail('Dockerfile: потрібен envsubst з default.conf.template (див. nginx-default-tpl.mdc)')
54
352
  }
55
353
  }
56
354
 
57
355
  if (existsSync('.vscode/extensions.json')) {
58
- const ext = JSON.parse(await readFile('.vscode/extensions.json', 'utf8'))
356
+ const extRaw = await readFile('.vscode/extensions.json', 'utf8')
357
+ const ext = JSON.parse(extRaw)
59
358
  if (ext.recommendations?.includes('ahmadalli.vscode-nginx-conf')) {
60
359
  pass('extensions.json містить ahmadalli.vscode-nginx-conf')
61
360
  } else {
62
361
  fail('extensions.json не містить ahmadalli.vscode-nginx-conf')
63
362
  }
363
+ } else {
364
+ fail('Очікується .vscode/extensions.json з ahmadalli.vscode-nginx-conf (див. nginx-default-tpl.mdc)')
64
365
  }
65
366
 
66
367
  if (existsSync('.vscode/settings.json')) {
67
- const s = JSON.parse(await readFile('.vscode/settings.json', 'utf8'))
368
+ const settingsRaw = await readFile('.vscode/settings.json', 'utf8')
369
+ const s = JSON.parse(settingsRaw)
370
+ if (s['editor.formatOnSave'] === true) {
371
+ pass('settings.json: editor.formatOnSave увімкнено')
372
+ } else {
373
+ fail('settings.json: увімкни editor.formatOnSave: true (див. nginx-default-tpl.mdc)')
374
+ }
68
375
  if (s['[nginx]']?.['editor.defaultFormatter'] === 'ahmadalli.vscode-nginx-conf') {
69
- pass('settings.json: nginx formatter налаштовано')
376
+ pass('settings.json: [nginx] defaultFormatter налаштовано')
70
377
  } else {
71
- fail('settings.json: [nginx] defaultFormatter має бути ahmadalli.vscode-nginx-conf')
378
+ fail('settings.json: [nginx].editor.defaultFormatter має бути ahmadalli.vscode-nginx-conf')
72
379
  }
380
+ } else {
381
+ fail('Очікується .vscode/settings.json з форматером nginx і formatOnSave (див. nginx-default-tpl.mdc)')
73
382
  }
74
383
 
75
384
  return exitCode