@nitra/cursor 1.8.152 → 1.8.154

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/auto-rules.md CHANGED
@@ -22,7 +22,7 @@ js-pino - якщо присутній хоч один js файл, не в мо
22
22
 
23
23
  js-mssql - якщо в хоч одному package.json в секції dependencies присутній пакет mssql
24
24
 
25
- js-bun-db - якщо в хоч одному package.json в секції dependencies присутній пакет pg або mysql2 або є імпорт sql/SQL з Bun (приклад: import { sql } from "bun")
25
+ js-bun-db - якщо в хоч одному package.json в секції dependencies присутній пакет pg, pg-format або mysql2 або є імпорт sql/SQL з Bun (приклад: import { sql } from "bun")
26
26
 
27
27
  k8s - якщо присутня хоч одна директорія k8s
28
28
 
package/mdc/js-bun-db.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Використання pg / mysql2 / Bun SQL у Node.js та Bun
3
3
  alwaysApply: true
4
- version: '1.2'
4
+ version: '1.3'
5
5
  ---
6
6
 
7
7
  ## Підтримувані версії баз даних
@@ -10,12 +10,14 @@ PostgreSQL 18+, MariaDB 10.6+ (сумісний з MySQL-протоколом,
10
10
 
11
11
  ## Заміна на Bun native SQL
12
12
 
13
- Якщо в проєкті використовуються бібліотеки `pg` або `mysql2`, їх потрібно замінити на Bun native SQL: <https://bun.com/docs/runtime/sql>.
13
+ Якщо в проєкті використовуються бібліотеки `pg`, `pg-format` або `mysql2`, їх потрібно замінити на Bun native SQL: <https://bun.com/docs/runtime/sql>.
14
14
 
15
- - Видалити з `dependencies`: `pg`, `pg-pool`, `pg-native`, `mysql`, `mysql2`.
15
+ - Видалити з `dependencies`: `pg`, `pg-pool`, `pg-native`, `pg-format`, `mysql`, `mysql2`.
16
16
  - Видалити з коду: усі `import` / `require` цих пакетів та власні обгортки над ними.
17
17
  - Замінити на `import { sql, SQL } from 'bun'` — Bun має вбудований клієнт із пулом, prepared statements та tagged templates.
18
18
 
19
+ `pg-format` — це ручне форматування SQL через escape (`format('... %L ...', value)`); такі рядки легко поламати неправильним типом, locale-залежним escape або забутим `%L`. Tagged template Bun SQL параметризує значення нативно (`sql\`... ${value} ...\``) і не лишає простору для injection — окремий «форматер» не потрібен.
20
+
19
21
  ## Підключення (singleton + env)
20
22
 
21
23
  Дефолтний експорт `sql` з `'bun'` сам читає змінні середовища (`DATABASE_URL`, `POSTGRES_URL`, `MYSQL_URL`, `PGHOST`/`PGUSER`/... та `MYSQL_HOST`/`MYSQL_USER`/...) і керує пулом — окремий `Pool` як у `pg` створювати не треба.
@@ -142,9 +144,9 @@ function getUser(id) {
142
144
 
143
145
  `new SQL(...)` має створюватись **один раз** на рівні модуля. Bun сам тримає пул (`max`, `idleTimeout`, `maxLifetime`) — окремих `Pool`/`Client` як у `pg` не потрібно.
144
146
 
145
- ### Не лишати `pg` / `mysql2` поряд із Bun SQL
147
+ ### Не лишати `pg` / `pg-format` / `mysql2` поряд із Bun SQL
146
148
 
147
- Якщо в коді з'явився `import { sql } from 'bun'`, то `pg` та `mysql2` мають бути прибрані і з `dependencies`, і з імпортів — щоб не лишалось двох паралельних шляхів до БД.
149
+ Якщо в коді з'явився `import { sql } from 'bun'`, то `pg`, `pg-format` та `mysql2` мають бути прибрані і з `dependencies`, і з імпортів — щоб не лишалось двох паралельних шляхів до БД та ручного форматування поряд із параметризованими template literal.
148
150
 
149
151
  ## Перевірка
150
152
 
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.25'
3
+ version: '1.26'
4
4
  globs: "**/k8s/**/*.yaml"
5
5
  alwaysApply: false
6
6
  ---
@@ -266,6 +266,26 @@ data:
266
266
 
267
267
  - Після перенесення маніфестів у **`base`** та overlays і перевірки (**`check k8s`**, **`lint-k8s`**) **видали** застарілі файли та директорії, які замінені новою схемою (дубльовані копії, колишні шляхи без Kustomize), щоб у репозиторії не залишалося зайвих або суперечливих маніфестів.
268
268
 
269
+ ### Зміна image — через `images:`, не через `patches[]`
270
+
271
+ Підміну image у Pod-шаблоні Deployment в overlay роби директивою `images:`, а не JSON6902-патчем `op: replace` на `/spec/template/spec/containers/<N>/image`. `images:` — канонічний механізм Kustomize (матчить за іменем образу, не за індексом контейнера, тож стійкий до перестановки).
272
+
273
+ - У **`name`** — те, що **дослівно** стоїть у `image:` у base (або в попередньому шарі) **без тегу**: інакше підміна не спрацює. Тег у `name` зайвий — Kustomize матчить лише за іменем.
274
+ - **`newName`** — кінцеве ім'я образу (як правило, збігається з `name`); `**newTag**` — тег для прода. Якщо `newTag` дорівнює тегу, який вже в base, його можна не вказувати.
275
+ - **`digest`** (`@sha256:…`) у `name` / `newName` не чіпай — це не тег.
276
+
277
+ ```yaml title="k8s/prod/kustomization.yaml (фрагмент)"
278
+ images:
279
+ - name: europe-west4-docker.pkg.dev/abie-ua/c/apply-on-invoice-discount
280
+ newName: europe-west4-docker.pkg.dev/abie-ua/c/apply-on-invoice-discount
281
+ newTag: v2025-04-29
282
+ ```
283
+
284
+ **`check k8s` автоматично** для кожного `kustomization.yaml`:
285
+
286
+ 1. конвертує JSON6902-патч `op: replace` на `/spec/template/spec/containers/<N>/image` (target `kind: Deployment`) у запис `images:` (резолвить оригінальний image у base через `resources:` / `bases` / `components` / `crds`); якщо в `patches[]` залишається лише ця операція — патч прибирається повністю;
287
+ 2. чистить існуючий блок `images:` — зрізає `:tag` з `name` і видаляє `newTag`, який збігається з відрізаним тегом.
288
+
269
289
  ## Ingress → Gateway API (GKE)
270
290
 
271
291
  Якщо в дереві **`k8s`** трапляється маніфест з **`kind: Ingress`**, його потрібно **замінити на Gateway API**, а не залишати Ingress.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.152",
3
+ "version": "1.8.154",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -2,7 +2,7 @@
2
2
  * Автовизначення правил і skills для `.n-cursor.json` за умовами з `npm/bin/auto-rules.md`.
3
3
  *
4
4
  * Модуль аналізує дерево проєкту (наявність файлів/директорій, `gql\`...\`` у source,
5
- * залежності `mssql` / `pg` / `mysql2` у `package.json`, імпорт `sql`/`SQL` з `bun`, кореневий
5
+ * залежності `mssql` / `pg` / `pg-format` / `mysql2` у `package.json`, імпорт `sql`/`SQL` з `bun`, кореневий
6
6
  * `package.json`) та повертає ідентифікатори правил і skills, які потрібно автододати.
7
7
  *
8
8
  * Також враховує винятки `disable-rules` і `disable-skills`: елементи з цих списків не
@@ -435,9 +435,9 @@ export async function detectAutoRulesAndSkills({
435
435
  )
436
436
  const isAbie = typeof repositoryUrl === 'string' && repositoryUrl.toLowerCase().includes(ABIE_REPOSITORY_URL_MARKER)
437
437
  const isMonorepo = isMonorepoPackage(packageJsonParsed)
438
- const depHits = await collectDependencyKeysPresentInPackageJsonTree(root, ['mssql', 'pg', 'mysql2'])
438
+ const depHits = await collectDependencyKeysPresentInPackageJsonTree(root, ['mssql', 'pg', 'pg-format', 'mysql2'])
439
439
  const hasMssqlDependency = depHits.has('mssql')
440
- const hasJsBunDbSignal = depHits.has('pg') || depHits.has('mysql2') || facts.hasBunSqlImport
440
+ const hasJsBunDbSignal = depHits.has('pg') || depHits.has('pg-format') || depHits.has('mysql2') || facts.hasBunSqlImport
441
441
 
442
442
  /** @type {string[]} */
443
443
  const detectedRules = []
@@ -2,8 +2,10 @@
2
2
  * Перевіряє правило js-bun-db.mdc.
3
3
  *
4
4
  * 1) У жодному `package.json` (включно з workspace-пакетами) у `dependencies` не повинно
5
- * бути `pg` чи `mysql2` — ці бібліотеки треба замінити на Bun native SQL
5
+ * бути `pg`, `pg-format` чи `mysql2` — ці бібліотеки треба замінити на Bun native SQL
6
6
  * (`import { sql, SQL } from 'bun'`, https://bun.com/docs/runtime/sql).
7
+ * `pg-format` — ручне форматування SQL через escape; tagged template Bun SQL
8
+ * параметризує значення нативно і не лишає простору для injection.
7
9
  *
8
10
  * 2) Якщо в коді використовується Bun SQL (імпорт `sql`/`SQL` з `'bun'`), додатково
9
11
  * перевіряє небезпечні патерни:
@@ -28,7 +30,7 @@ import {
28
30
  import { walkDir } from './utils/walkDir.mjs'
29
31
 
30
32
  /** Імена забороненої залежності у будь-якому `package.json`. */
31
- const FORBIDDEN_DEPENDENCIES = Object.freeze(['pg', 'mysql2'])
33
+ const FORBIDDEN_DEPENDENCIES = Object.freeze(['pg', 'pg-format', 'mysql2'])
32
34
 
33
35
  /**
34
36
  * @param {unknown} v parsed JSON
@@ -89,6 +89,13 @@
89
89
  * `replacements[].path` має вказувати на наявний у репозиторії файл (`.yaml` / `.yml`) або каталог; інакше
90
90
  * помилка `check k8s` (k8s.mdc).
91
91
  *
92
+ * **Images у Kustomize — `images:`, не patch:** для кожного `kustomization.yaml` автоматично:
93
+ * (а) конвертує JSON6902 `op: replace` на `/spec/template/spec/containers/<N>/image` (target `kind: Deployment`) у
94
+ * запис **`images:`** — `name` береться з оригінального `image:` у base (без тегу), `newName` — з patch.value (без тегу),
95
+ * `newTag` — лише якщо тег у patch.value відрізняється від тега в base; якщо `patches[]` після цього порожній — ключ
96
+ * прибирається; (б) чистить існуючий блок **`images:`** — зрізає `:tag` з `name` (digest `@…` не чіпає) і видаляє
97
+ * `newTag`, який збігається з відрізаним тегом.
98
+ *
92
99
  * **HPA / PDB тільки з Deployment у шарі base:** у дереві Kustomize з `…/k8s/…/base/kustomization.yaml` не
93
100
  * дозволяти `HorizontalPodAutoscaler` / `PodDisruptionBudget` у `resources` / `bases` / `components` / `crds`
94
101
  * (рекурсивно), якщо в цьому ж дереві немає документа **`Deployment`** у жодному YAML під **`…/k8s/…/base/`**. У
@@ -99,7 +106,7 @@ import { existsSync } from 'node:fs'
99
106
  import { readFile, readdir, stat, unlink, writeFile } from 'node:fs/promises'
100
107
  import { basename, dirname, join, relative, resolve } from 'node:path'
101
108
 
102
- import { parseAllDocuments } from 'yaml'
109
+ import { isSeq, parseAllDocuments, parseDocument } from 'yaml'
103
110
 
104
111
  import { createCheckReporter } from './utils/check-reporter.mjs'
105
112
  import { walkDir } from './utils/walkDir.mjs'
@@ -5040,6 +5047,454 @@ async function validateDeploymentHpaPdbAndTopology(root, yamlFilesAbs, fail, pas
5040
5047
  }
5041
5048
  }
5042
5049
 
5050
+ /**
5051
+ * Розбирає рядок image на ім'я і тег, з виявленням digest.
5052
+ *
5053
+ * - `foo@sha256:…` — `hasDigest: true`, тег не виділяється;
5054
+ * - `localhost:5000/foo` (порт без тегу) — теж без виділення;
5055
+ * - `localhost:5000/foo:tag` — `name: 'localhost:5000/foo'`, `tag: 'tag'`.
5056
+ *
5057
+ * Тег визначається лише по **останній** двокрапці; якщо після неї є `/` — це порт реєстру, не тег.
5058
+ * @param {string} image рядок image
5059
+ * @returns {{ name: string, tag: string | null, hasDigest: boolean }}
5060
+ */
5061
+ export function splitImageNameTagDigest(image) {
5062
+ if (image.includes('@')) {
5063
+ return { name: image, tag: null, hasDigest: true }
5064
+ }
5065
+ const lastColon = image.lastIndexOf(':')
5066
+ if (lastColon === -1) {
5067
+ return { name: image, tag: null, hasDigest: false }
5068
+ }
5069
+ const after = image.slice(lastColon + 1)
5070
+ if (after === '' || after.includes('/')) {
5071
+ return { name: image, tag: null, hasDigest: false }
5072
+ }
5073
+ return { name: image.slice(0, lastColon), tag: after, hasDigest: false }
5074
+ }
5075
+
5076
+ /**
5077
+ * Розпаковує YAML-скаляр з оточуючими лапками (single або double). Інші стилі (block scalar) — повертає як є.
5078
+ * @param {string} raw сирий рядок-значення без trailing whitespace/comment
5079
+ * @returns {{ unquoted: string, quote: '' | "'" | '"' }}
5080
+ */
5081
+ function parseQuotedYamlScalar(raw) {
5082
+ if (raw.length >= 2) {
5083
+ const first = raw.charAt(0)
5084
+ const last = raw.charAt(raw.length - 1)
5085
+ if (first === '"' && last === '"') {
5086
+ return { unquoted: raw.slice(1, -1), quote: '"' }
5087
+ }
5088
+ if (first === "'" && last === "'") {
5089
+ return { unquoted: raw.slice(1, -1).replaceAll("''", "'"), quote: "'" }
5090
+ }
5091
+ }
5092
+ return { unquoted: raw, quote: '' }
5093
+ }
5094
+
5095
+ /**
5096
+ * Загортає скаляр у лапки, повертаючи оригінальний стиль.
5097
+ * @param {string} value значення без оточуючих лапок
5098
+ * @param {'' | "'" | '"'} quote стиль лапок
5099
+ * @returns {string} рядок-скаляр для запису назад у YAML
5100
+ */
5101
+ function requoteYamlScalar(value, quote) {
5102
+ if (quote === '"') return `"${value}"`
5103
+ if (quote === "'") return `'${value.replaceAll("'", "''")}'`
5104
+ return value
5105
+ }
5106
+
5107
+ /** Regex: рядок верхнього рівня з ключем `images:` (без значення в тому ж рядку). */
5108
+ const KUSTOMIZATION_IMAGES_KEY_RE = /^images:\s*(?:#.*)?$/u
5109
+ /** Regex: початок елемента масиву (`-` з відступом). Групує сам відступ перед `-`. */
5110
+ const KUSTOMIZATION_LIST_ITEM_RE = /^(\s*)-\s/u
5111
+ /** Regex: значення поля (name / newName / newTag) у рядку, з опційним `- ` префіксом. */
5112
+ const KUSTOMIZATION_IMAGE_FIELD_RE = /^(\s*(?:-\s+)?)(name|newName|newTag):(\s+)(\S.*?)(\s*(?:#.*)?)$/u
5113
+
5114
+ /**
5115
+ * Автофікс блоку `images:` у kustomization.yaml: зрізає `:tag` з `name` (digest `@…` не чіпає)
5116
+ * і видаляє `newTag`, який збігається зі зрізаним тегом. Працює рядково, зберігаючи коментарі
5117
+ * й форматування.
5118
+ * @param {string} raw вміст файлу
5119
+ * @returns {{ changed: boolean, content: string }}
5120
+ */
5121
+ export function cleanupKustomizationImagesInYamlText(raw) {
5122
+ const eol = raw.includes('\r\n') ? '\r\n' : '\n'
5123
+ const lines = raw.split(YAML_LINE_SPLIT_RE)
5124
+
5125
+ let imagesStart = -1
5126
+ for (let i = 0; i < lines.length; i++) {
5127
+ if (KUSTOMIZATION_IMAGES_KEY_RE.test(lines[i])) {
5128
+ imagesStart = i + 1
5129
+ break
5130
+ }
5131
+ }
5132
+ if (imagesStart === -1) return { changed: false, content: raw }
5133
+
5134
+ let imagesEnd = lines.length
5135
+ for (let i = imagesStart; i < lines.length; i++) {
5136
+ const l = lines[i]
5137
+ if (l === '' || /^\s/u.test(l) || /^#/u.test(l)) continue
5138
+ imagesEnd = i
5139
+ break
5140
+ }
5141
+
5142
+ /** @type {Array<{ start: number, end: number }>} */
5143
+ const entries = []
5144
+ let curStart = -1
5145
+ for (let i = imagesStart; i < imagesEnd; i++) {
5146
+ if (KUSTOMIZATION_LIST_ITEM_RE.test(lines[i])) {
5147
+ if (curStart >= 0) entries.push({ start: curStart, end: i })
5148
+ curStart = i
5149
+ }
5150
+ }
5151
+ if (curStart >= 0) entries.push({ start: curStart, end: imagesEnd })
5152
+
5153
+ /** @type {Map<number, string>} */
5154
+ const replacements = new Map()
5155
+ /** @type {Set<number>} */
5156
+ const removals = new Set()
5157
+ let changed = false
5158
+
5159
+ for (const { start, end } of entries) {
5160
+ /** @type {string | null} */
5161
+ let strippedTag = null
5162
+ let nameProcessed = false
5163
+ /** @type {{ lineIdx: number, value: string } | null} */
5164
+ let newTagInfo = null
5165
+ let newTagProcessed = false
5166
+
5167
+ for (let i = start; i < end; i++) {
5168
+ const m = lines[i].match(KUSTOMIZATION_IMAGE_FIELD_RE)
5169
+ if (m === null) continue
5170
+ const [, prefix, key, sep, valueRaw, trailing] = m
5171
+ if (key === 'name' && !nameProcessed) {
5172
+ nameProcessed = true
5173
+ const { unquoted, quote } = parseQuotedYamlScalar(valueRaw)
5174
+ const split = splitImageNameTagDigest(unquoted)
5175
+ if (split.tag !== null) {
5176
+ const newLine = `${prefix}name:${sep}${requoteYamlScalar(split.name, quote)}${trailing}`
5177
+ if (newLine !== lines[i]) {
5178
+ replacements.set(i, newLine)
5179
+ changed = true
5180
+ }
5181
+ strippedTag = split.tag
5182
+ }
5183
+ } else if (key === 'newTag' && !newTagProcessed) {
5184
+ newTagProcessed = true
5185
+ const { unquoted } = parseQuotedYamlScalar(valueRaw)
5186
+ newTagInfo = { lineIdx: i, value: unquoted }
5187
+ }
5188
+ }
5189
+
5190
+ if (newTagInfo !== null && strippedTag !== null && newTagInfo.value === strippedTag) {
5191
+ removals.add(newTagInfo.lineIdx)
5192
+ changed = true
5193
+ }
5194
+ }
5195
+
5196
+ if (!changed) return { changed: false, content: raw }
5197
+
5198
+ /** @type {string[]} */
5199
+ const out = []
5200
+ for (let i = 0; i < lines.length; i++) {
5201
+ if (removals.has(i)) continue
5202
+ out.push(replacements.has(i) ? replacements.get(i) : lines[i])
5203
+ }
5204
+ return { changed: true, content: out.join(eol) }
5205
+ }
5206
+
5207
+ /** Regex: JSON6902 path для image окремого контейнера у Pod-шаблоні Deployment. */
5208
+ const KUSTOMIZATION_DEPLOYMENT_CONTAINER_IMAGE_PATH_RE = /^\/spec\/template\/spec\/containers\/(\d+)\/image$/u
5209
+
5210
+ /**
5211
+ * Якщо `patchObj` — JSON6902 з єдиною операцією `replace` на шляху image-контейнера у `Deployment`,
5212
+ * повертає `{ deployName, containerIndex, newImage }`. Інакше null.
5213
+ * @param {unknown} patchObj елемент масиву `patches[]`
5214
+ * @returns {{ deployName: string, containerIndex: number, newImage: string } | null}
5215
+ */
5216
+ export function imageReplaceDeploymentPatchInfo(patchObj) {
5217
+ if (patchObj === null || typeof patchObj !== 'object' || Array.isArray(patchObj)) return null
5218
+ const pr = /** @type {Record<string, unknown>} */ (patchObj)
5219
+ const target = pr.target
5220
+ if (target === null || typeof target !== 'object' || Array.isArray(target)) return null
5221
+ const t = /** @type {Record<string, unknown>} */ (target)
5222
+ if (t.kind !== 'Deployment') return null
5223
+ if (typeof t.name !== 'string' || t.name.trim() === '') return null
5224
+ if (typeof pr.patch !== 'string') return null
5225
+
5226
+ let parsedArr
5227
+ try {
5228
+ for (const d of parseAllDocuments(pr.patch.trim())) {
5229
+ if (d.errors.length === 0) {
5230
+ const j = d.toJSON()
5231
+ if (Array.isArray(j)) {
5232
+ parsedArr = j
5233
+ break
5234
+ }
5235
+ }
5236
+ }
5237
+ } catch {
5238
+ return null
5239
+ }
5240
+ if (!Array.isArray(parsedArr) || parsedArr.length !== 1) return null
5241
+ const op = parsedArr[0]
5242
+ if (op === null || typeof op !== 'object' || Array.isArray(op)) return null
5243
+ const oo = /** @type {Record<string, unknown>} */ (op)
5244
+ if (typeof oo.op !== 'string' || oo.op.toLowerCase() !== 'replace') return null
5245
+ if (typeof oo.path !== 'string') return null
5246
+ const m = oo.path.match(KUSTOMIZATION_DEPLOYMENT_CONTAINER_IMAGE_PATH_RE)
5247
+ if (m === null) return null
5248
+ if (typeof oo.value !== 'string' || oo.value.trim() === '') return null
5249
+
5250
+ return {
5251
+ deployName: t.name.trim(),
5252
+ containerIndex: Number(m[1]),
5253
+ newImage: oo.value.trim()
5254
+ }
5255
+ }
5256
+
5257
+ /**
5258
+ * Шукає `Deployment.spec.template.spec.containers[N].image` у YAML-файлі.
5259
+ * @param {string} absPath абсолютний шлях до YAML-файлу
5260
+ * @param {string} deployName ім'я Deployment
5261
+ * @param {number} containerIndex індекс контейнера
5262
+ * @returns {Promise<string | null>} рядок image або null
5263
+ */
5264
+ async function findDeploymentContainerImageInFile(absPath, deployName, containerIndex) {
5265
+ const raw = await tryReadFileUtf8(absPath)
5266
+ if (raw === undefined) return null
5267
+ const docs = tryParseAllYamlDocs(raw)
5268
+ if (docs === undefined) return null
5269
+ for (const d of docs) {
5270
+ if (d.errors.length !== 0) continue
5271
+ const o = d.toJSON()
5272
+ if (o === null || typeof o !== 'object' || Array.isArray(o)) continue
5273
+ const oo = /** @type {Record<string, unknown>} */ (o)
5274
+ if (oo.kind !== 'Deployment') continue
5275
+ const meta = oo.metadata
5276
+ if (meta === null || typeof meta !== 'object' || Array.isArray(meta)) continue
5277
+ if (/** @type {Record<string, unknown>} */ (meta).name !== deployName) continue
5278
+ const spec = oo.spec
5279
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) continue
5280
+ const tmpl = /** @type {Record<string, unknown>} */ (spec).template
5281
+ if (tmpl === null || typeof tmpl !== 'object' || Array.isArray(tmpl)) continue
5282
+ const podSpec = /** @type {Record<string, unknown>} */ (tmpl).spec
5283
+ if (podSpec === null || typeof podSpec !== 'object' || Array.isArray(podSpec)) continue
5284
+ const containers = /** @type {Record<string, unknown>} */ (podSpec).containers
5285
+ if (!Array.isArray(containers) || containerIndex < 0 || containerIndex >= containers.length) continue
5286
+ const c = containers[containerIndex]
5287
+ if (c === null || typeof c !== 'object' || Array.isArray(c)) continue
5288
+ const img = /** @type {Record<string, unknown>} */ (c).image
5289
+ if (typeof img === 'string' && img.trim() !== '') return img.trim()
5290
+ }
5291
+ return null
5292
+ }
5293
+
5294
+ /**
5295
+ * Рекурсивно проходить дерево kustomization (resources / bases / components / crds), шукаючи
5296
+ * `Deployment` із заданим іменем; повертає image потрібного контейнера або null, якщо не знайдено.
5297
+ * @param {string} kustAbs абсолютний шлях до kustomization.yaml (поточний шар)
5298
+ * @param {string} rootNorm нормалізований корінь репо
5299
+ * @param {string} deployName ім'я Deployment
5300
+ * @param {number} containerIndex індекс контейнера
5301
+ * @param {Set<string>} visited нормалізовані відвідані kustomization.yaml
5302
+ * @returns {Promise<string | null>} image або null
5303
+ */
5304
+ async function walkKustomizationForDeploymentImage(kustAbs, rootNorm, deployName, containerIndex, visited) {
5305
+ const norm = resolve(kustAbs)
5306
+ if (visited.has(norm)) return null
5307
+ visited.add(norm)
5308
+
5309
+ const obj = await readFirstYamlObject(norm)
5310
+ if (obj === null) return null
5311
+ const kustDir = dirname(norm)
5312
+ const refs = pathsFromKustomizationObject(obj)
5313
+
5314
+ for (const ref of refs) {
5315
+ if (typeof ref !== 'string' || ref.includes('://') || ref.trim() === '') continue
5316
+ const resolved = resolve(kustDir, ref.trim())
5317
+ if (!resolvedFilePathIsUnderRoot(rootNorm, resolved)) continue
5318
+ let st
5319
+ try {
5320
+ st = await stat(resolved)
5321
+ } catch {
5322
+ continue
5323
+ }
5324
+ if (st.isFile() && YAML_EXTENSION_RE.test(resolved)) {
5325
+ const img = await findDeploymentContainerImageInFile(resolved, deployName, containerIndex)
5326
+ if (img !== null) return img
5327
+ } else if (st.isDirectory()) {
5328
+ const childK = join(resolved, 'kustomization.yaml')
5329
+ if (existsSync(childK)) {
5330
+ const img = await walkKustomizationForDeploymentImage(childK, rootNorm, deployName, containerIndex, visited)
5331
+ if (img !== null) return img
5332
+ }
5333
+ }
5334
+ }
5335
+ return null
5336
+ }
5337
+
5338
+ /**
5339
+ * Конвертує JSON6902 image-replace patches у `images:` для одного kustomization.yaml.
5340
+ *
5341
+ * Алгоритм:
5342
+ *
5343
+ * 1. Читає файл, парсить як **Document** (yaml lib), щоб максимально зберегти форматування.
5344
+ * 2. Для кожного `patches[i]` з `target.kind: Deployment` і єдиною операцією
5345
+ * `op: replace` на `path: /spec/template/spec/containers/N/image` шукає оригінальний image
5346
+ * через `walkKustomizationForDeploymentImage` (resources → recursively).
5347
+ * 3. Будує `images:` запис: `name = base_image_без_тегу/digest`, `newName = patch_value_без_тегу`,
5348
+ * `newTag = patch_value_тег`, **якщо** він відрізняється від тега base.
5349
+ * 4. Видаляє відповідні patches; якщо `patches:` стає порожнім — видаляє ключ.
5350
+ * 5. Записує файл назад через `Document.toString()`.
5351
+ * @param {string} kustAbs абсолютний шлях до kustomization.yaml
5352
+ * @param {string} rootNorm нормалізований корінь репо
5353
+ * @returns {Promise<{ changed: boolean, content?: string, errors: string[] }>}
5354
+ */
5355
+ export async function convertImagePatchesToImagesInKustomization(kustAbs, rootNorm) {
5356
+ const raw = await tryReadFileUtf8(kustAbs)
5357
+ if (raw === undefined) return { changed: false, errors: [] }
5358
+
5359
+ let doc
5360
+ try {
5361
+ doc = parseDocument(raw)
5362
+ } catch {
5363
+ return { changed: false, errors: [] }
5364
+ }
5365
+ if (doc.errors.length > 0) return { changed: false, errors: [] }
5366
+
5367
+ const obj = doc.toJSON()
5368
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
5369
+ return { changed: false, errors: [] }
5370
+ }
5371
+ const rec = /** @type {Record<string, unknown>} */ (obj)
5372
+ if (rec.kind !== 'Kustomization') return { changed: false, errors: [] }
5373
+ if (typeof rec.apiVersion !== 'string' || !rec.apiVersion.startsWith(KUSTOMIZE_CONFIG_API_PREFIX)) {
5374
+ return { changed: false, errors: [] }
5375
+ }
5376
+ if (!Array.isArray(rec.patches)) return { changed: false, errors: [] }
5377
+
5378
+ /** @type {Array<{ index: number, info: { deployName: string, containerIndex: number, newImage: string } }>} */
5379
+ const candidates = []
5380
+ for (const [i, p] of rec.patches.entries()) {
5381
+ const info = imageReplaceDeploymentPatchInfo(p)
5382
+ if (info !== null) candidates.push({ index: i, info })
5383
+ }
5384
+ if (candidates.length === 0) return { changed: false, errors: [] }
5385
+
5386
+ /** @type {Array<{ index: number, name: string, newName: string, newTag: string | null }>} */
5387
+ const conversions = []
5388
+ /** @type {string[]} */
5389
+ const errors = []
5390
+
5391
+ for (const { index, info } of candidates) {
5392
+ /** @type {Set<string>} */
5393
+ const visited = new Set()
5394
+ const baseImage = await walkKustomizationForDeploymentImage(
5395
+ kustAbs,
5396
+ rootNorm,
5397
+ info.deployName,
5398
+ info.containerIndex,
5399
+ visited
5400
+ )
5401
+ if (baseImage === null) {
5402
+ errors.push(
5403
+ `patches[${index}]: не знайдено Deployment ${info.deployName}.containers[${info.containerIndex}].image у дереві resources — конвертацію патча в images: пропущено (k8s.mdc)`
5404
+ )
5405
+ continue
5406
+ }
5407
+ const baseSplit = splitImageNameTagDigest(baseImage)
5408
+ if (baseSplit.hasDigest) {
5409
+ errors.push(
5410
+ `patches[${index}]: base image для ${info.deployName} містить digest (${baseImage}) — автоконвертацію патча пропущено (k8s.mdc)`
5411
+ )
5412
+ continue
5413
+ }
5414
+ const newSplit = splitImageNameTagDigest(info.newImage)
5415
+ if (newSplit.hasDigest) {
5416
+ errors.push(
5417
+ `patches[${index}]: значення патча для ${info.deployName} містить digest (${info.newImage}) — автоконвертацію пропущено (k8s.mdc)`
5418
+ )
5419
+ continue
5420
+ }
5421
+ const finalNewTag = newSplit.tag !== null && newSplit.tag !== baseSplit.tag ? newSplit.tag : null
5422
+ conversions.push({
5423
+ index,
5424
+ name: baseSplit.name,
5425
+ newName: newSplit.name,
5426
+ newTag: finalNewTag
5427
+ })
5428
+ }
5429
+
5430
+ if (conversions.length === 0) return { changed: false, errors }
5431
+
5432
+ const patchesNode = doc.get('patches', true)
5433
+ if (!isSeq(patchesNode)) return { changed: false, errors }
5434
+
5435
+ const removeIdx = new Set(conversions.map(c => c.index))
5436
+ for (let i = patchesNode.items.length - 1; i >= 0; i--) {
5437
+ if (removeIdx.has(i)) patchesNode.delete(i)
5438
+ }
5439
+ if (patchesNode.items.length === 0) {
5440
+ doc.delete('patches')
5441
+ }
5442
+
5443
+ let imagesNode = doc.get('images', true)
5444
+ if (!isSeq(imagesNode)) {
5445
+ imagesNode = doc.createNode([])
5446
+ doc.set('images', imagesNode)
5447
+ }
5448
+
5449
+ for (const { name, newName, newTag } of conversions) {
5450
+ const entry = newTag === null ? { name, newName } : { name, newName, newTag }
5451
+ imagesNode.add(doc.createNode(entry))
5452
+ }
5453
+
5454
+ const content = doc.toString()
5455
+ if (content === raw) return { changed: false, errors }
5456
+ return { changed: true, content, errors }
5457
+ }
5458
+
5459
+ /**
5460
+ * Прохід для всіх `kustomization.yaml`: конвертує image-replace patches у `images:`,
5461
+ * потім чистить `images:` (зрізає теги в `name`, видаляє надлишкові `newTag`).
5462
+ * @param {string} root корінь репо
5463
+ * @param {string[]} yamlFilesAbs всі yaml під k8s
5464
+ * @param {(msg: string) => void} fail колбек повідомлення про помилку
5465
+ * @param {(msg: string) => void} pass колбек успішного повідомлення
5466
+ * @returns {Promise<void>}
5467
+ */
5468
+ async function autofixKustomizationImagesYaml(root, yamlFilesAbs, fail, pass) {
5469
+ const rootNorm = resolve(root)
5470
+ const kusts = yamlFilesAbs.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')
5471
+ for (const kustAbs of kusts) {
5472
+ const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
5473
+ try {
5474
+ const r = await convertImagePatchesToImagesInKustomization(kustAbs, rootNorm)
5475
+ for (const err of r.errors) fail(`${rel}: ${err}`)
5476
+ if (r.changed && r.content !== undefined) {
5477
+ await writeFile(kustAbs, r.content, 'utf8')
5478
+ pass(`${rel}: image-replace patch(es) конвертовано в images: (k8s.mdc)`)
5479
+ }
5480
+ } catch (error) {
5481
+ const msg = error instanceof Error ? error.message : String(error)
5482
+ fail(`${rel}: не вдалося конвертувати image-replace patches → images: (${msg})`)
5483
+ }
5484
+ try {
5485
+ const raw = await readFile(kustAbs, 'utf8')
5486
+ const r = cleanupKustomizationImagesInYamlText(raw)
5487
+ if (r.changed) {
5488
+ await writeFile(kustAbs, r.content, 'utf8')
5489
+ pass(`${rel}: images: cleanup — зрізано :tag з name й видалено надлишкове newTag (k8s.mdc)`)
5490
+ }
5491
+ } catch (error) {
5492
+ const msg = error instanceof Error ? error.message : String(error)
5493
+ fail(`${rel}: не вдалося очистити images: (${msg})`)
5494
+ }
5495
+ }
5496
+ }
5497
+
5043
5498
  /**
5044
5499
  * Перевіряє відповідність проєкту правилам k8s.mdc.
5045
5500
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
@@ -5063,6 +5518,8 @@ export async function check() {
5063
5518
 
5064
5519
  pass(`YAML у k8s: ${yamlFiles.length} файл(ів)`)
5065
5520
 
5521
+ await autofixKustomizationImagesYaml(root, yamlFiles, fail, pass)
5522
+
5066
5523
  assertNoForbiddenK8sDevPaths(yamlFiles, root, fail)
5067
5524
 
5068
5525
  const kustomizeManagedRel = await collectKustomizeManagedRelPaths(root, yamlFiles)