@nitra/cursor 1.8.156 → 1.8.157

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.
@@ -5056,7 +5056,7 @@ async function validateDeploymentHpaPdbAndTopology(root, yamlFilesAbs, fail, pas
5056
5056
  *
5057
5057
  * Тег визначається лише по **останній** двокрапці; якщо після неї є `/` — це порт реєстру, не тег.
5058
5058
  * @param {string} image рядок image
5059
- * @returns {{ name: string, tag: string | null, hasDigest: boolean }}
5059
+ * @returns {{ name: string, tag: string | null, hasDigest: boolean }} ім'я (з реєстром/портом), тег (null якщо немає), та чи є digest
5060
5060
  */
5061
5061
  export function splitImageNameTagDigest(image) {
5062
5062
  if (image.includes('@')) {
@@ -5076,12 +5076,12 @@ export function splitImageNameTagDigest(image) {
5076
5076
  /**
5077
5077
  * Розпаковує YAML-скаляр з оточуючими лапками (single або double). Інші стилі (block scalar) — повертає як є.
5078
5078
  * @param {string} raw сирий рядок-значення без trailing whitespace/comment
5079
- * @returns {{ unquoted: string, quote: '' | "'" | '"' }}
5079
+ * @returns {{ unquoted: string, quote: '' | "'" | '"' }} текст без лапок та сам стиль лапок (порожній, якщо їх не було)
5080
5080
  */
5081
5081
  function parseQuotedYamlScalar(raw) {
5082
5082
  if (raw.length >= 2) {
5083
5083
  const first = raw.charAt(0)
5084
- const last = raw.charAt(raw.length - 1)
5084
+ const last = raw.at(-1)
5085
5085
  if (first === '"' && last === '"') {
5086
5086
  return { unquoted: raw.slice(1, -1), quote: '"' }
5087
5087
  }
@@ -5108,100 +5108,203 @@ function requoteYamlScalar(value, quote) {
5108
5108
  const KUSTOMIZATION_IMAGES_KEY_RE = /^images:\s*(?:#.*)?$/u
5109
5109
  /** Regex: початок елемента масиву (`-` з відступом). Групує сам відступ перед `-`. */
5110
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
5111
+ /**
5112
+ * Regex: значення поля (name / newName / newTag) у рядку, з опційним `- ` префіксом.
5113
+ * Захоплює увесь хвіст рядка одним капчером `valueWithTrailing`; коментар і trailing-пробіли
5114
+ * розбираються після матчу через {@link splitYamlValueAndTrailing} — це уникає вкладеного
5115
+ * `?` (lazy) + `?` (опційний хвіст), на який реагує `sonarjs/slow-regex`.
5116
+ */
5117
+ const KUSTOMIZATION_IMAGE_FIELD_RE = /^(\s*(?:-\s+)?)(name|newName|newTag):(\s+)(\S[^\n]*)$/u
5118
+
5119
+ /**
5120
+ * Розщеплює правий бік YAML-рядка `<value>[<пробіли>#<comment>]` на «чистий» value та trailing
5121
+ * (пробіли + коментар), без використання regex з backtracking.
5122
+ * @param {string} valueWithTrailing «сирий» хвіст рядка після `<key>:<sep>`
5123
+ * @returns {{ value: string, trailing: string }} розбиті частини
5124
+ */
5125
+ function splitYamlValueAndTrailing(valueWithTrailing) {
5126
+ const hashIdx = findCommentStart(valueWithTrailing)
5127
+ const upTo = hashIdx === -1 ? valueWithTrailing.length : hashIdx
5128
+ let valueEnd = upTo
5129
+ while (valueEnd > 0) {
5130
+ const code = valueWithTrailing.codePointAt(valueEnd - 1)
5131
+ if (code !== 32 && code !== 9 && code !== 10 && code !== 13) break
5132
+ valueEnd--
5133
+ }
5134
+ const value = valueWithTrailing.slice(0, valueEnd)
5135
+ return { value, trailing: valueWithTrailing.slice(valueEnd) }
5136
+ }
5137
+
5138
+ /**
5139
+ * Знаходить індекс стартового `#`-коментаря: перший `#`, перед яким є пробіл (інакше `#`
5140
+ * — частина значення). Повертає -1, якщо коментаря немає.
5141
+ * @param {string} s рядок (хвіст YAML-рядка)
5142
+ * @returns {number} індекс стартового `#` або -1
5143
+ */
5144
+ function findCommentStart(s) {
5145
+ for (let i = 0; i < s.length; i++) {
5146
+ if (s.codePointAt(i) !== 35) continue
5147
+ if (i === 0) return i
5148
+ const prev = s.codePointAt(i - 1)
5149
+ if (prev === 32 || prev === 9) return i
5150
+ }
5151
+ return -1
5152
+ }
5153
+ /** Regex: рядок у блоці `images:` починається з пробілу/таба (належить блоку). */
5154
+ const KUSTOMIZATION_BLOCK_INDENT_RE = /^\s/u
5113
5155
 
5114
5156
  /**
5115
5157
  * Автофікс блоку `images:` у kustomization.yaml: зрізає `:tag` з `name` (digest `@…` не чіпає)
5116
5158
  * і видаляє `newTag`, який збігається зі зрізаним тегом. Працює рядково, зберігаючи коментарі
5117
5159
  * й форматування.
5118
5160
  * @param {string} raw вміст файлу
5119
- * @returns {{ changed: boolean, content: string }}
5161
+ * @returns {{ changed: boolean, content: string }} прапорець, чи були зміни, та (за потреби) очищений текст
5120
5162
  */
5121
5163
  export function cleanupKustomizationImagesInYamlText(raw) {
5122
5164
  const eol = raw.includes('\r\n') ? '\r\n' : '\n'
5123
5165
  const lines = raw.split(YAML_LINE_SPLIT_RE)
5124
5166
 
5167
+ const imagesRange = findImagesBlockRange(lines)
5168
+ if (imagesRange === null) return { changed: false, content: raw }
5169
+
5170
+ const entries = splitImagesBlockEntries(lines, imagesRange.start, imagesRange.end)
5171
+
5172
+ /** @type {Map<number, string>} */
5173
+ const replacements = new Map()
5174
+ /** @type {Set<number>} */
5175
+ const removals = new Set()
5176
+ let changed = false
5177
+
5178
+ for (const entry of entries) {
5179
+ if (processImagesEntry(lines, entry, replacements, removals)) changed = true
5180
+ }
5181
+
5182
+ if (!changed) return { changed: false, content: raw }
5183
+
5184
+ /** @type {string[]} */
5185
+ const out = []
5186
+ for (const [i, line] of lines.entries()) {
5187
+ if (removals.has(i)) continue
5188
+ out.push(replacements.has(i) ? replacements.get(i) : line)
5189
+ }
5190
+ return { changed: true, content: out.join(eol) }
5191
+ }
5192
+
5193
+ /**
5194
+ * Знаходить діапазон рядків YAML, що належать блоку `images:` верхнього рівня.
5195
+ * @param {string[]} lines рядки файлу
5196
+ * @returns {{ start: number, end: number } | null} `start` — перший рядок ПІСЛЯ ключа `images:`,
5197
+ * `end` — перший рядок не з блоку (виключно), або null, якщо ключа немає
5198
+ */
5199
+ function findImagesBlockRange(lines) {
5125
5200
  let imagesStart = -1
5126
- for (let i = 0; i < lines.length; i++) {
5127
- if (KUSTOMIZATION_IMAGES_KEY_RE.test(lines[i])) {
5201
+ for (const [i, line] of lines.entries()) {
5202
+ if (KUSTOMIZATION_IMAGES_KEY_RE.test(line)) {
5128
5203
  imagesStart = i + 1
5129
5204
  break
5130
5205
  }
5131
5206
  }
5132
- if (imagesStart === -1) return { changed: false, content: raw }
5133
-
5207
+ if (imagesStart === -1) return null
5134
5208
  let imagesEnd = lines.length
5135
5209
  for (let i = imagesStart; i < lines.length; i++) {
5136
5210
  const l = lines[i]
5137
- if (l === '' || /^\s/u.test(l) || /^#/u.test(l)) continue
5211
+ if (l === '' || KUSTOMIZATION_BLOCK_INDENT_RE.test(l) || l.startsWith('#')) continue
5138
5212
  imagesEnd = i
5139
5213
  break
5140
5214
  }
5215
+ return { start: imagesStart, end: imagesEnd }
5216
+ }
5141
5217
 
5218
+ /**
5219
+ * Розбиває діапазон рядків блоку `images:` на елементи списку (`- name: …`).
5220
+ * @param {string[]} lines рядки файлу
5221
+ * @param {number} blockStart перший рядок блоку (включно)
5222
+ * @param {number} blockEnd перший рядок не з блоку (виключно)
5223
+ * @returns {Array<{ start: number, end: number }>} діапазони рядків кожного елемента
5224
+ */
5225
+ function splitImagesBlockEntries(lines, blockStart, blockEnd) {
5142
5226
  /** @type {Array<{ start: number, end: number }>} */
5143
5227
  const entries = []
5144
5228
  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
- }
5229
+ for (let i = blockStart; i < blockEnd; i++) {
5230
+ if (!KUSTOMIZATION_LIST_ITEM_RE.test(lines[i])) continue
5231
+ if (curStart >= 0) entries.push({ start: curStart, end: i })
5232
+ curStart = i
5150
5233
  }
5151
- if (curStart >= 0) entries.push({ start: curStart, end: imagesEnd })
5234
+ if (curStart >= 0) entries.push({ start: curStart, end: blockEnd })
5235
+ return entries
5236
+ }
5152
5237
 
5153
- /** @type {Map<number, string>} */
5154
- const replacements = new Map()
5155
- /** @type {Set<number>} */
5156
- const removals = new Set()
5238
+ /**
5239
+ * Обробляє один елемент `images[]`: збирає `name` (зрізає :tag) і `newTag` (видаляє, якщо
5240
+ * збігається зі зрізаним). Записує плановані заміни/видалення в передані колекції.
5241
+ * @param {string[]} lines рядки файлу
5242
+ * @param {{ start: number, end: number }} entry діапазон рядків елемента
5243
+ * @param {Map<number, string>} replacements буфер замін «номер_рядка → новий рядок»
5244
+ * @param {Set<number>} removals буфер видалень «номер_рядка»
5245
+ * @returns {boolean} true, якщо для цього елемента запланована хоча б одна зміна
5246
+ */
5247
+ function processImagesEntry(lines, entry, replacements, removals) {
5248
+ /** @type {string | null} */
5249
+ let strippedTag = null
5250
+ let nameProcessed = false
5251
+ /** @type {{ lineIdx: number, value: string } | null} */
5252
+ let newTagInfo = null
5253
+ let newTagProcessed = false
5157
5254
  let changed = false
5158
5255
 
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 }
5256
+ for (let i = entry.start; i < entry.end; i++) {
5257
+ const parsed = parseImagesEntryLine(lines[i])
5258
+ if (parsed === null) continue
5259
+ if (parsed.key === 'name' && !nameProcessed) {
5260
+ nameProcessed = true
5261
+ const result = applyNameStripTag(lines[i], parsed)
5262
+ if (result.replacement !== null) {
5263
+ replacements.set(i, result.replacement)
5264
+ changed = true
5187
5265
  }
5266
+ strippedTag = result.strippedTag
5267
+ } else if (parsed.key === 'newTag' && !newTagProcessed) {
5268
+ newTagProcessed = true
5269
+ const { unquoted } = parseQuotedYamlScalar(parsed.value)
5270
+ newTagInfo = { lineIdx: i, value: unquoted }
5188
5271
  }
5272
+ }
5189
5273
 
5190
- if (newTagInfo !== null && strippedTag !== null && newTagInfo.value === strippedTag) {
5191
- removals.add(newTagInfo.lineIdx)
5192
- changed = true
5193
- }
5274
+ if (newTagInfo !== null && strippedTag !== null && newTagInfo.value === strippedTag) {
5275
+ removals.add(newTagInfo.lineIdx)
5276
+ changed = true
5194
5277
  }
5278
+ return changed
5279
+ }
5195
5280
 
5196
- if (!changed) return { changed: false, content: raw }
5281
+ /**
5282
+ * Парсить рядок YAML-поля `name|newName|newTag`, повертає його складники або null, якщо рядок
5283
+ * не відповідає формату.
5284
+ * @param {string} line рядок YAML
5285
+ * @returns {{ prefix: string, key: 'name' | 'newName' | 'newTag', sep: string, value: string, trailing: string } | null}
5286
+ * Розібрані поля або null
5287
+ */
5288
+ function parseImagesEntryLine(line) {
5289
+ const m = line.match(KUSTOMIZATION_IMAGE_FIELD_RE)
5290
+ if (m === null) return null
5291
+ const [, prefix, key, sep, valueWithTrailing] = m
5292
+ const { value, trailing } = splitYamlValueAndTrailing(valueWithTrailing)
5293
+ return { prefix, key: /** @type {'name' | 'newName' | 'newTag'} */ (key), sep, value, trailing }
5294
+ }
5197
5295
 
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) }
5296
+ /**
5297
+ * Якщо `value` містить тег — повертає новий рядок без тега та сам тег. Інакше — null/null.
5298
+ * @param {string} originalLine оригінальний рядок YAML
5299
+ * @param {{ prefix: string, sep: string, value: string, trailing: string }} parsed розібрані складники
5300
+ * @returns {{ replacement: string | null, strippedTag: string | null }} планована заміна (або null) і зрізаний тег
5301
+ */
5302
+ function applyNameStripTag(originalLine, parsed) {
5303
+ const { unquoted, quote } = parseQuotedYamlScalar(parsed.value)
5304
+ const split = splitImageNameTagDigest(unquoted)
5305
+ if (split.tag === null) return { replacement: null, strippedTag: null }
5306
+ const newLine = `${parsed.prefix}name:${parsed.sep}${requoteYamlScalar(split.name, quote)}${parsed.trailing}`
5307
+ return { replacement: newLine === originalLine ? null : newLine, strippedTag: split.tag }
5205
5308
  }
5206
5309
 
5207
5310
  /** Regex: JSON6902 path для image окремого контейнера у Pod-шаблоні Deployment. */
@@ -5211,47 +5314,87 @@ const KUSTOMIZATION_DEPLOYMENT_CONTAINER_IMAGE_PATH_RE = /^\/spec\/template\/spe
5211
5314
  * Якщо `patchObj` — JSON6902 з єдиною операцією `replace` на шляху image-контейнера у `Deployment`,
5212
5315
  * повертає `{ deployName, containerIndex, newImage }`. Інакше null.
5213
5316
  * @param {unknown} patchObj елемент масиву `patches[]`
5214
- * @returns {{ deployName: string, containerIndex: number, newImage: string } | null}
5317
+ * @returns {{ deployName: string, containerIndex: number, newImage: string } | null} інформація про патч або null, якщо це не очікувана JSON6902-операція
5215
5318
  */
5216
5319
  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)
5320
+ const pr = asPlainObject(patchObj)
5321
+ if (pr === null) return null
5322
+ const deployName = deploymentTargetName(pr.target)
5323
+ if (deployName === null) return null
5324
+ if (typeof pr.patch !== 'string') return null
5325
+
5326
+ const parsedArr = tryParseSingleJson6902Array(pr.patch)
5327
+ if (parsedArr === null) return null
5328
+ const op = asPlainObject(parsedArr[0])
5329
+ if (op === null) return null
5330
+
5331
+ const containerIndex = singleImageReplaceContainerIndex(op)
5332
+ if (containerIndex === null) return null
5333
+ if (typeof op.value !== 'string' || op.value.trim() === '') return null
5334
+
5335
+ return {
5336
+ deployName,
5337
+ containerIndex,
5338
+ newImage: op.value.trim()
5339
+ }
5340
+ }
5341
+
5342
+ /**
5343
+ * Перевіряє, що значення — це plain-object (не null, не масив), і повертає його з типом
5344
+ * `Record<string, unknown>` або null. Скорочує перевірки на початку гілок.
5345
+ * @param {unknown} value будь-що з YAML/JSON
5346
+ * @returns {Record<string, unknown> | null} вузол як plain-object або null
5347
+ */
5348
+ function asPlainObject(value) {
5349
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return null
5350
+ return /** @type {Record<string, unknown>} */ (value)
5351
+ }
5352
+
5353
+ /**
5354
+ * Перевіряє, що `target` — це Deployment з непорожнім ім'ям, і повертає це ім'я (trimmed).
5355
+ * @param {unknown} target значення `patches[i].target`
5356
+ * @returns {string | null} ім'я Deployment або null
5357
+ */
5358
+ function deploymentTargetName(target) {
5359
+ const t = asPlainObject(target)
5360
+ if (t === null) return null
5222
5361
  if (t.kind !== 'Deployment') return null
5223
5362
  if (typeof t.name !== 'string' || t.name.trim() === '') return null
5224
- if (typeof pr.patch !== 'string') return null
5363
+ return t.name.trim()
5364
+ }
5225
5365
 
5366
+ /**
5367
+ * Парсить `patch`-рядок як YAML-масив з рівно однією операцією JSON6902.
5368
+ * @param {string} patch текст YAML-масиву JSON6902-операцій
5369
+ * @returns {unknown[] | null} масив однієї операції або null
5370
+ */
5371
+ function tryParseSingleJson6902Array(patch) {
5226
5372
  let parsedArr
5227
5373
  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
- }
5374
+ for (const d of parseAllDocuments(patch.trim())) {
5375
+ if (d.errors.length !== 0) continue
5376
+ const j = d.toJSON()
5377
+ if (Array.isArray(j)) {
5378
+ parsedArr = j
5379
+ break
5235
5380
  }
5236
5381
  }
5237
5382
  } catch {
5238
5383
  return null
5239
5384
  }
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
5385
+ return Array.isArray(parsedArr) && parsedArr.length === 1 ? parsedArr : null
5386
+ }
5249
5387
 
5250
- return {
5251
- deployName: t.name.trim(),
5252
- containerIndex: Number(m[1]),
5253
- newImage: oo.value.trim()
5254
- }
5388
+ /**
5389
+ * Якщо операція — `op: replace` на шляху контейнера-image, повертає індекс контейнера.
5390
+ * @param {Record<string, unknown>} op об'єкт операції JSON6902
5391
+ * @returns {number | null} індекс контейнера у `containers[N]` або null
5392
+ */
5393
+ function singleImageReplaceContainerIndex(op) {
5394
+ if (typeof op.op !== 'string' || op.op.toLowerCase() !== 'replace') return null
5395
+ if (typeof op.path !== 'string') return null
5396
+ const m = op.path.match(KUSTOMIZATION_DEPLOYMENT_CONTAINER_IMAGE_PATH_RE)
5397
+ return m === null ? null : Number(m[1])
5255
5398
  }
5256
5399
 
5257
5400
  /**
@@ -5268,29 +5411,50 @@ async function findDeploymentContainerImageInFile(absPath, deployName, container
5268
5411
  if (docs === undefined) return null
5269
5412
  for (const d of docs) {
5270
5413
  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()
5414
+ const img = imageFromDeploymentDoc(d.toJSON(), deployName, containerIndex)
5415
+ if (img !== null) return img
5290
5416
  }
5291
5417
  return null
5292
5418
  }
5293
5419
 
5420
+ /**
5421
+ * Витягує `containers[N].image` з YAML-документа, якщо він — Deployment з відповідним іменем.
5422
+ * @param {unknown} doc розпаршений YAML як plain JS-обʼєкт
5423
+ * @param {string} deployName очікуване `metadata.name` Deployment
5424
+ * @param {number} containerIndex індекс контейнера у `spec.template.spec.containers[]`
5425
+ * @returns {string | null} обрізаний `image` або null
5426
+ */
5427
+ function imageFromDeploymentDoc(doc, deployName, containerIndex) {
5428
+ const oo = asPlainObject(doc)
5429
+ if (oo === null || oo.kind !== 'Deployment') return null
5430
+ const meta = asPlainObject(oo.metadata)
5431
+ if (meta === null || meta.name !== deployName) return null
5432
+ const containers = containersOfDeployment(oo)
5433
+ if (containers === null) return null
5434
+ if (containerIndex < 0 || containerIndex >= containers.length) return null
5435
+ const c = asPlainObject(containers[containerIndex])
5436
+ if (c === null) return null
5437
+ const img = c.image
5438
+ if (typeof img === 'string' && img.trim() !== '') return img.trim()
5439
+ return null
5440
+ }
5441
+
5442
+ /**
5443
+ * Витягує `spec.template.spec.containers` з обʼєкта Deployment (або null, якщо структура неповна).
5444
+ * @param {Record<string, unknown>} deployment plain-object документу Deployment
5445
+ * @returns {unknown[] | null} масив контейнерів або null
5446
+ */
5447
+ function containersOfDeployment(deployment) {
5448
+ const spec = asPlainObject(deployment.spec)
5449
+ if (spec === null) return null
5450
+ const tmpl = asPlainObject(spec.template)
5451
+ if (tmpl === null) return null
5452
+ const podSpec = asPlainObject(tmpl.spec)
5453
+ if (podSpec === null) return null
5454
+ const containers = podSpec.containers
5455
+ return Array.isArray(containers) ? containers : null
5456
+ }
5457
+
5294
5458
  /**
5295
5459
  * Рекурсивно проходить дерево kustomization (resources / bases / components / crds), шукаючи
5296
5460
  * `Deployment` із заданим іменем; повертає image потрібного контейнера або null, якщо не знайдено.
@@ -5312,24 +5476,52 @@ async function walkKustomizationForDeploymentImage(kustAbs, rootNorm, deployName
5312
5476
  const refs = pathsFromKustomizationObject(obj)
5313
5477
 
5314
5478
  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
- }
5479
+ const resolved = normalizeKustomizationRef(ref, kustDir, rootNorm)
5480
+ if (resolved === null) continue
5481
+ const img = await imageFromResolvedKustomizationRef(resolved, rootNorm, deployName, containerIndex, visited)
5482
+ if (img !== null) return img
5483
+ }
5484
+ return null
5485
+ }
5486
+
5487
+ /**
5488
+ * Перевіряє та нормалізує посилання `resources[]/components[]/bases[]` з kustomization.yaml.
5489
+ * Пропускає неприйнятні (URL, порожні, поза межами репо) посилання.
5490
+ * @param {unknown} ref значення з масиву посилань
5491
+ * @param {string} kustDir абсолютний каталог поточної kustomization
5492
+ * @param {string} rootNorm нормалізований корінь репо
5493
+ * @returns {string | null} абсолютний резолвлений шлях або null, якщо посилання треба пропустити
5494
+ */
5495
+ function normalizeKustomizationRef(ref, kustDir, rootNorm) {
5496
+ if (typeof ref !== 'string' || ref.includes('://') || ref.trim() === '') return null
5497
+ const resolved = resolve(kustDir, ref.trim())
5498
+ return resolvedFilePathIsUnderRoot(rootNorm, resolved) ? resolved : null
5499
+ }
5500
+
5501
+ /**
5502
+ * Намагається отримати image для `<deployName>:<containerIndex>` з резолвленого посилання
5503
+ * (файл або підкаталог з kustomization.yaml).
5504
+ * @param {string} resolvedAbs абсолютний шлях файлу або каталогу
5505
+ * @param {string} rootNorm нормалізований корінь репо
5506
+ * @param {string} deployName ім'я Deployment
5507
+ * @param {number} containerIndex індекс контейнера
5508
+ * @param {Set<string>} visited нормалізовані відвідані kustomization.yaml
5509
+ * @returns {Promise<string | null>} знайдений image або null
5510
+ */
5511
+ async function imageFromResolvedKustomizationRef(resolvedAbs, rootNorm, deployName, containerIndex, visited) {
5512
+ let st
5513
+ try {
5514
+ st = await stat(resolvedAbs)
5515
+ } catch {
5516
+ return null
5517
+ }
5518
+ if (st.isFile() && YAML_EXTENSION_RE.test(resolvedAbs)) {
5519
+ return findDeploymentContainerImageInFile(resolvedAbs, deployName, containerIndex)
5520
+ }
5521
+ if (st.isDirectory()) {
5522
+ const childK = join(resolvedAbs, 'kustomization.yaml')
5523
+ if (existsSync(childK)) {
5524
+ return walkKustomizationForDeploymentImage(childK, rootNorm, deployName, containerIndex, visited)
5333
5525
  }
5334
5526
  }
5335
5527
  return null
@@ -5350,30 +5542,49 @@ async function walkKustomizationForDeploymentImage(kustAbs, rootNorm, deployName
5350
5542
  * 5. Записує файл назад через `Document.toString()`.
5351
5543
  * @param {string} kustAbs абсолютний шлях до kustomization.yaml
5352
5544
  * @param {string} rootNorm нормалізований корінь репо
5353
- * @returns {Promise<{ changed: boolean, content?: string, errors: string[] }>}
5545
+ * @returns {Promise<{ changed: boolean, content?: string, errors: string[] }>} прапорець змін, новий вміст (за наявності) і список нефатальних помилок під час конвертації
5354
5546
  */
5355
5547
  export async function convertImagePatchesToImagesInKustomization(kustAbs, rootNorm) {
5356
5548
  const raw = await tryReadFileUtf8(kustAbs)
5357
5549
  if (raw === undefined) return { changed: false, errors: [] }
5358
5550
 
5551
+ const parsed = parseKustomizationWithPatches(raw)
5552
+ if (parsed === null) return { changed: false, errors: [] }
5553
+ const { doc, candidates } = parsed
5554
+ if (candidates.length === 0) return { changed: false, errors: [] }
5555
+
5556
+ const { conversions, errors } = await buildPatchToImageConversions(kustAbs, rootNorm, candidates)
5557
+ if (conversions.length === 0) return { changed: false, errors }
5558
+
5559
+ if (!applyConversionsToDoc(doc, conversions)) return { changed: false, errors }
5560
+
5561
+ const content = doc.toString()
5562
+ if (content === raw) return { changed: false, errors }
5563
+ return { changed: true, content, errors }
5564
+ }
5565
+
5566
+ /**
5567
+ * Парсить kustomization.yaml як Document і повертає його разом зі списком кандидатів-патчів
5568
+ * (`patches[i]` що відповідає `image-replace` для Deployment). Повертає null, якщо документ не
5569
+ * розпарсився, не є Kustomization або не має масиву `patches:`.
5570
+ * @param {string} raw текст файлу
5571
+ * @returns {{ doc: ReturnType<typeof parseDocument>, candidates: Array<{ index: number, info: { deployName: string, containerIndex: number, newImage: string } }> } | null}
5572
+ * document і список кандидатів, або null
5573
+ */
5574
+ function parseKustomizationWithPatches(raw) {
5359
5575
  let doc
5360
5576
  try {
5361
5577
  doc = parseDocument(raw)
5362
5578
  } catch {
5363
- return { changed: false, errors: [] }
5579
+ return null
5364
5580
  }
5365
- if (doc.errors.length > 0) return { changed: false, errors: [] }
5581
+ if (doc.errors.length > 0) return null
5366
5582
 
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: [] }
5583
+ const rec = asPlainObject(doc.toJSON())
5584
+ if (rec === null) return null
5585
+ if (rec.kind !== 'Kustomization') return null
5586
+ if (typeof rec.apiVersion !== 'string' || !rec.apiVersion.startsWith(KUSTOMIZE_CONFIG_API_PREFIX)) return null
5587
+ if (!Array.isArray(rec.patches)) return null
5377
5588
 
5378
5589
  /** @type {Array<{ index: number, info: { deployName: string, containerIndex: number, newImage: string } }>} */
5379
5590
  const candidates = []
@@ -5381,56 +5592,82 @@ export async function convertImagePatchesToImagesInKustomization(kustAbs, rootNo
5381
5592
  const info = imageReplaceDeploymentPatchInfo(p)
5382
5593
  if (info !== null) candidates.push({ index: i, info })
5383
5594
  }
5384
- if (candidates.length === 0) return { changed: false, errors: [] }
5595
+ return { doc, candidates }
5596
+ }
5385
5597
 
5598
+ /**
5599
+ * Для кожного кандидата шукає базовий image у дереві resources та формує запис конвертації
5600
+ * (або повідомлення про помилку, чому конвертація неможлива).
5601
+ * @param {string} kustAbs абсолютний шлях до kustomization.yaml
5602
+ * @param {string} rootNorm нормалізований корінь репо
5603
+ * @param {Array<{ index: number, info: { deployName: string, containerIndex: number, newImage: string } }>} candidates кандидати з `patches[]`
5604
+ * @returns {Promise<{ conversions: Array<{ index: number, name: string, newName: string, newTag: string | null }>, errors: string[] }>}
5605
+ * результати конвертації та зібрані нефатальні помилки
5606
+ */
5607
+ async function buildPatchToImageConversions(kustAbs, rootNorm, candidates) {
5386
5608
  /** @type {Array<{ index: number, name: string, newName: string, newTag: string | null }>} */
5387
5609
  const conversions = []
5388
5610
  /** @type {string[]} */
5389
5611
  const errors = []
5390
5612
 
5391
5613
  for (const { index, info } of candidates) {
5392
- /** @type {Set<string>} */
5393
- const visited = new Set()
5394
5614
  const baseImage = await walkKustomizationForDeploymentImage(
5395
5615
  kustAbs,
5396
5616
  rootNorm,
5397
5617
  info.deployName,
5398
5618
  info.containerIndex,
5399
- visited
5619
+ new Set()
5400
5620
  )
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
- })
5621
+ const conversion = buildConversionForCandidate(index, info, baseImage, errors)
5622
+ if (conversion !== null) conversions.push(conversion)
5428
5623
  }
5429
5624
 
5430
- if (conversions.length === 0) return { changed: false, errors }
5625
+ return { conversions, errors }
5626
+ }
5627
+
5628
+ /**
5629
+ * Будує одну конвертацію `patches[index]` → `images[]` запис з відповідним `newTag`.
5630
+ * Якщо щось не так (немає baseImage, digest у base/new) — додає текст у `errors` і повертає null.
5631
+ * @param {number} index індекс патча в `patches[]`
5632
+ * @param {{ deployName: string, containerIndex: number, newImage: string }} info результат `imageReplaceDeploymentPatchInfo`
5633
+ * @param {string | null} baseImage знайдений базовий image або null
5634
+ * @param {string[]} errors буфер нефатальних помилок (мутується)
5635
+ * @returns {{ index: number, name: string, newName: string, newTag: string | null } | null} запис конвертації або null
5636
+ */
5637
+ function buildConversionForCandidate(index, info, baseImage, errors) {
5638
+ if (baseImage === null) {
5639
+ errors.push(
5640
+ `patches[${index}]: не знайдено Deployment ${info.deployName}.containers[${info.containerIndex}].image у дереві resources — конвертацію патча в images: пропущено (k8s.mdc)`
5641
+ )
5642
+ return null
5643
+ }
5644
+ const baseSplit = splitImageNameTagDigest(baseImage)
5645
+ if (baseSplit.hasDigest) {
5646
+ errors.push(
5647
+ `patches[${index}]: base image для ${info.deployName} містить digest (${baseImage}) — автоконвертацію патча пропущено (k8s.mdc)`
5648
+ )
5649
+ return null
5650
+ }
5651
+ const newSplit = splitImageNameTagDigest(info.newImage)
5652
+ if (newSplit.hasDigest) {
5653
+ errors.push(
5654
+ `patches[${index}]: значення патча для ${info.deployName} містить digest (${info.newImage}) — автоконвертацію пропущено (k8s.mdc)`
5655
+ )
5656
+ return null
5657
+ }
5658
+ const finalNewTag = newSplit.tag !== null && newSplit.tag !== baseSplit.tag ? newSplit.tag : null
5659
+ return { index, name: baseSplit.name, newName: newSplit.name, newTag: finalNewTag }
5660
+ }
5431
5661
 
5662
+ /**
5663
+ * Видаляє конвертовані елементи з `patches:` (за потреби — і сам ключ) і дописує `images:`.
5664
+ * @param {ReturnType<typeof parseDocument>} doc документ kustomization.yaml
5665
+ * @param {Array<{ index: number, name: string, newName: string, newTag: string | null }>} conversions конвертації
5666
+ * @returns {boolean} true, якщо мутації відбулися (документ можна серіалізувати)
5667
+ */
5668
+ function applyConversionsToDoc(doc, conversions) {
5432
5669
  const patchesNode = doc.get('patches', true)
5433
- if (!isSeq(patchesNode)) return { changed: false, errors }
5670
+ if (!isSeq(patchesNode)) return false
5434
5671
 
5435
5672
  const removeIdx = new Set(conversions.map(c => c.index))
5436
5673
  for (let i = patchesNode.items.length - 1; i >= 0; i--) {
@@ -5445,15 +5682,11 @@ export async function convertImagePatchesToImagesInKustomization(kustAbs, rootNo
5445
5682
  imagesNode = doc.createNode([])
5446
5683
  doc.set('images', imagesNode)
5447
5684
  }
5448
-
5449
5685
  for (const { name, newName, newTag } of conversions) {
5450
5686
  const entry = newTag === null ? { name, newName } : { name, newName, newTag }
5451
5687
  imagesNode.add(doc.createNode(entry))
5452
5688
  }
5453
-
5454
- const content = doc.toString()
5455
- if (content === raw) return { changed: false, errors }
5456
- return { changed: true, content, errors }
5689
+ return true
5457
5690
  }
5458
5691
 
5459
5692
  /**
@@ -5470,28 +5703,53 @@ async function autofixKustomizationImagesYaml(root, yamlFilesAbs, fail, pass) {
5470
5703
  const kusts = yamlFilesAbs.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')
5471
5704
  for (const kustAbs of kusts) {
5472
5705
  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})`)
5706
+ await runImagePatchToImagesConversion(kustAbs, rel, rootNorm, fail, pass)
5707
+ await runKustomizationImagesCleanup(kustAbs, rel, fail, pass)
5708
+ }
5709
+ }
5710
+
5711
+ /**
5712
+ * Прогон автоконвертації `patches[].image-replace` → `images:` для одного kustomization.yaml.
5713
+ * @param {string} kustAbs абсолютний шлях до kustomization.yaml
5714
+ * @param {string} rel posix-шлях відносно кореня репо (для повідомлень)
5715
+ * @param {string} rootNorm нормалізований корінь репо
5716
+ * @param {(msg: string) => void} fail колбек повідомлення про помилку
5717
+ * @param {(msg: string) => void} pass колбек успішного повідомлення
5718
+ * @returns {Promise<void>} завершується після конвертації або реєстрації помилки
5719
+ */
5720
+ async function runImagePatchToImagesConversion(kustAbs, rel, rootNorm, fail, pass) {
5721
+ try {
5722
+ const r = await convertImagePatchesToImagesInKustomization(kustAbs, rootNorm)
5723
+ for (const err of r.errors) fail(`${rel}: ${err}`)
5724
+ if (r.changed && r.content !== undefined) {
5725
+ await writeFile(kustAbs, r.content, 'utf8')
5726
+ pass(`${rel}: image-replace patch(es) конвертовано в images: (k8s.mdc)`)
5483
5727
  }
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})`)
5728
+ } catch (error) {
5729
+ const msg = error instanceof Error ? error.message : String(error)
5730
+ fail(`${rel}: не вдалося конвертувати image-replace patches → images: (${msg})`)
5731
+ }
5732
+ }
5733
+
5734
+ /**
5735
+ * Прогон чистильника `images:` (зрізає `:tag` з name й видаляє надлишковий `newTag`).
5736
+ * @param {string} kustAbs абсолютний шлях до kustomization.yaml
5737
+ * @param {string} rel posix-шлях відносно кореня репо (для повідомлень)
5738
+ * @param {(msg: string) => void} fail колбек повідомлення про помилку
5739
+ * @param {(msg: string) => void} pass колбек успішного повідомлення
5740
+ * @returns {Promise<void>} завершується після очищення або реєстрації помилки
5741
+ */
5742
+ async function runKustomizationImagesCleanup(kustAbs, rel, fail, pass) {
5743
+ try {
5744
+ const raw = await readFile(kustAbs, 'utf8')
5745
+ const r = cleanupKustomizationImagesInYamlText(raw)
5746
+ if (r.changed) {
5747
+ await writeFile(kustAbs, r.content, 'utf8')
5748
+ pass(`${rel}: images: cleanup — зрізано :tag з name й видалено надлишкове newTag (k8s.mdc)`)
5494
5749
  }
5750
+ } catch (error) {
5751
+ const msg = error instanceof Error ? error.message : String(error)
5752
+ fail(`${rel}: не вдалося очистити images: (${msg})`)
5495
5753
  }
5496
5754
  }
5497
5755