@nitra/cursor 1.8.201 → 1.8.203

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/CHANGELOG.md CHANGED
@@ -4,6 +4,25 @@
4
4
 
5
5
  Формат — [Keep a Changelog](https://keepachangelog.com/uk/1.1.0/), нумерація — [SemVer](https://semver.org/lang/uk/).
6
6
 
7
+ ## [1.8.203] - 2026-05-07
8
+
9
+ ### Changed
10
+
11
+ - `check-k8s.mjs` (автоконверт `image-replace` patches → `images:`): тепер працює і для `patches[i].patch` із **кількома** ops, а не лише з одинокою image-replace op. Сканує всі ops у патчі, конвертує **кожну** `op: replace` на `/spec/template/spec/containers/<N>/image` (target `kind: Deployment`) у запис `images:`; якщо всі ops патча конвертовано — `patches[i]` видаляється повністю; інакше inline `patch:` переписується через `parseDocument` без конвертованих ops зі збереженням block-literal scalar (`|-`) і вихідного порядку решти ops. Реалізовано через нові функції `tryParseJson6902Array` (≥ 1 op, замість `tryParseSingleJson6902Array`) і `rewriteInlinePatchWithoutOps`; `imageReplaceDeploymentPatchInfo` повертає `{ deployName, totalOps, ops: [{ containerIndex, newImage, opIndex }] }` (раніше — одиничний `{ deployName, containerIndex, newImage }` лише за `length === 1`); `applyConversionsToDoc` групує конвертації по індексу патча й вирізає ops або сам патч за потреби. Сортування решти ops після видалення лишається поза цією зміною — за нього відповідає окрема перевірка `kustomizationInlinePatchOpsSortedViolation`.
12
+ - `mdc/k8s.mdc` (v1.26 → v1.27): уточнено крок 1 авто-перевірки в розділі «Зміна image — через `images:`, не через `patches[]`» — тепер описує і випадок, коли в `patches[i].patch` лишаються не-image ops (їх зберігає, у вихідному порядку, без коментарів).
13
+ - `check-js-lint.mjs` + `mdc/js-lint.mdc` (v1.16 → v1.17): мінімум `@nitra/eslint-config` піднято з `^3.8.0` до `^3.9.2`. Обґрунтування: з 3.9.2 у `getConfig` вбудовано ignore для `**/adr/**`, тож ADR-документи не валідуються ESLint, і консьюмерам не треба додавати цей glob у `eslint.config.js` локально. `nitraEslintConfigMeetsMinVersion` тепер повертає `false` для діапазонів `^3.8.x`–`^3.9.1`; `workspace:*` лишається ok без змін. Pass/fail-повідомлення `checkPackageJsonLintDeps` оновлено під новий мінімум; `for...in`-бан з 3.8.0 згадується як накопичена відмінність. Тести `nitraEslintConfigMeetsMinVersion` розширено: `^3.9.2`/`^3.9.10`/`^3.10.0`/`^4.0.0` — ok; `^3.9.1`/`^3.8.0`/`^3.6.12`/`^3.4.3` — ні.
14
+ - `bin/n-cursor.js` (`reexecIfPackageVersionChanged` + `spawnSync`-виклик): `process.env.NITRA_CURSOR_REEXEC` і `...process.env` замінено на `env.NITRA_CURSOR_REEXEC` і `...env` з `node:process` (`import { cwd, env } from 'node:process'`). Підстава: правило `js-run.mdc` забороняє прямий `process.env.*` у Node-коді; `NITRA_CURSOR_REEXEC` — опційна змінна (виставляється лише при re-exec), тож імпорт `env` з `node:process` (а не з `@nitra/check-env`) — канонічна форма для опційних. Поведінка не змінена; раніше `npm/scripts/check-js-run.mjs` помилявся на `bin/n-cursor.js:1136` (правило `process-env`), тепер intergation-test `check-* на реальному репозиторії` проходить.
15
+
16
+ ### Added
17
+
18
+ - `tests/check-k8s-images.test.mjs`: нова форма `imageReplaceDeploymentPatchInfo` (`ops`/`totalOps`/`opIndex`); e2e-тести на multi-op patch (image + `add nodeSelector`), три не-image ops + image у hasura-стилі (`add containers/-` + `add volumes` + `replace nodeSelector`), multi-image patch (containers/0 + containers/1 → обидва конвертовано, патч видаляється), mixed patch з digest у одному з image-values (звичайний tag конвертовано, digest op лишається у патчі) і одиничний digest-image (повертає `errors`, патч на диску не змінюється).
19
+
20
+ ## [1.8.202] - 2026-05-07
21
+
22
+ ### Added
23
+
24
+ - `bin/n-cursor.js`: новий хелпер `reexecIfPackageVersionChanged(effectivePackageRoot)` і його виклик у `runSync` одразу після `upgradeNitraCursorToLatestAndBunInstall`. Якщо self-upgrade встановив у `node_modules/@nitra/cursor` версію, відмінну від тієї, з якої стартував поточний процес (типово — npx-кеш), CLI спавнить `process.execPath <newBin> <args…>` через `spawnSync` (`stdio: 'inherit'`), додає в env `NITRA_CURSOR_REEXEC=1` і завершується з exit-кодом дочірнього процесу. Обґрунтування: ES-модулі (`RULE_MIGRATIONS`, `detectAutoRulesAndSkills`, списки правил) уже завантажені у V8 і нова логіка з-під свіжо встановленого пакета без re-exec невидима для поточного запуску — `import()` не вирішує цього, бо процес виконується з `bin/` у npx-кеші, а не з `node_modules/`. Захист від нескінченного циклу — раннє повернення при `process.env.NITRA_CURSOR_REEXEC === '1'`; додатково нічого не робить, якщо `effectivePackageRoot === BUNDLED_PACKAGE_ROOT` (реального апгрейду не сталося), якщо `version` не вдалося прочитати з обох `package.json`, або якщо у новому корені відсутній `bin/n-cursor.js`. `runChecks` свідомо не патчиться — він не виконує self-upgrade, тож версія процесу і пакета там завжди узгоджені. Імпорт `spawnSync` із `node:child_process` — єдина нова зовнішня залежність.
25
+
7
26
  ## [1.8.201] - 2026-05-07
8
27
 
9
28
  ### Changed
package/bin/n-cursor.js CHANGED
@@ -49,10 +49,11 @@
49
49
  * `node_modules/@nitra/cursor`, якщо пакет з’явився після встановлення.
50
50
  */
51
51
 
52
+ import { spawnSync } from 'node:child_process'
52
53
  import { existsSync } from 'node:fs'
53
54
  import { mkdir, readdir, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises'
54
55
  import { basename, dirname, join } from 'node:path'
55
- import { cwd } from 'node:process'
56
+ import { cwd, env } from 'node:process'
56
57
  import { fileURLToPath } from 'node:url'
57
58
 
58
59
  import { buildAgentsCommandBulletItems } from '../scripts/build-agents-commands.mjs'
@@ -1122,6 +1123,46 @@ async function readBundledVersionAt(packageRoot) {
1122
1123
  }
1123
1124
  }
1124
1125
 
1126
+ /**
1127
+ * Якщо `upgradeNitraCursorToLatestAndBunInstall` встановив у `node_modules/@nitra/cursor` версію,
1128
+ * відмінну від тієї, з якої стартував поточний процес (наприклад, з npx-кешу), запускає бінар нової
1129
+ * версії через `spawnSync` і завершує поточний процес із успадкованим exit-кодом. Re-exec потрібен,
1130
+ * бо ES-модулі вже завантажені у V8 (RULE_MIGRATIONS, detectAutoRulesAndSkills тощо) і нова логіка
1131
+ * без повної заміни процесу не підхопиться. Захист від нескінченного циклу — env `NITRA_CURSOR_REEXEC=1`.
1132
+ * @param {string} effectivePackageRoot шлях, повернутий `upgradeNitraCursorToLatestAndBunInstall`
1133
+ * @returns {Promise<void>} повертається лише якщо re-exec не потрібен (інакше викликає `process.exit`)
1134
+ */
1135
+ async function reexecIfPackageVersionChanged(effectivePackageRoot) {
1136
+ if (env.NITRA_CURSOR_REEXEC === '1') {
1137
+ return
1138
+ }
1139
+ if (effectivePackageRoot === BUNDLED_PACKAGE_ROOT) {
1140
+ return
1141
+ }
1142
+ const currentVersion = await readBundledVersionAt(BUNDLED_PACKAGE_ROOT)
1143
+ const installedVersion = await readBundledVersionAt(effectivePackageRoot)
1144
+ if (!currentVersion || !installedVersion || currentVersion === installedVersion) {
1145
+ return
1146
+ }
1147
+ const newBinPath = join(effectivePackageRoot, 'bin', 'n-cursor.js')
1148
+ if (!existsSync(newBinPath)) {
1149
+ return
1150
+ }
1151
+ console.log(
1152
+ `🔁 Перезапуск ${PACKAGE_NAME}: процес стартував на ${currentVersion}, ` +
1153
+ `після self-upgrade встановлено ${installedVersion}.\n` +
1154
+ ` Re-exec свіжого бінаря, щоб підхопити нову логіку (RULE_MIGRATIONS, auto-detect тощо).\n`
1155
+ )
1156
+ const result = spawnSync(process.execPath, [newBinPath, ...process.argv.slice(2)], {
1157
+ stdio: 'inherit',
1158
+ env: { ...env, NITRA_CURSOR_REEXEC: '1' }
1159
+ })
1160
+ if (result.error) {
1161
+ throw result.error
1162
+ }
1163
+ process.exit(typeof result.status === 'number' ? result.status : 1)
1164
+ }
1165
+
1125
1166
  /**
1126
1167
  * Копіює правила з каталогу `mdc/` установленого пакету та синхронізує `.cursor/rules`
1127
1168
  * @returns {Promise<void>}
@@ -1134,6 +1175,8 @@ async function runSync() {
1134
1175
  upgradeNitraCursorToLatestAndBunInstall(projectRoot, BUNDLED_PACKAGE_ROOT)
1135
1176
  )
1136
1177
 
1178
+ await reexecIfPackageVersionChanged(effectivePackageRoot)
1179
+
1137
1180
  const bundledMdcDir = join(effectivePackageRoot, 'mdc')
1138
1181
  const bundledSkillsDir = join(effectivePackageRoot, 'skills')
1139
1182
  const bundledAgentsTemplatePath = join(effectivePackageRoot, AGENTS_TEMPLATE_FILE)
package/mdc/js-lint.mdc CHANGED
@@ -1,10 +1,10 @@
1
1
  ---
2
2
  description: Перевірка JavaScript коду
3
3
  alwaysApply: true
4
- version: '1.16'
4
+ version: '1.17'
5
5
  ---
6
6
 
7
- **oxlint**, **ESLint**, **jscpd**. У скрипті **`lint-js`** і в CI — **`bunx oxlint`**, **`bunx eslint`**, **`bunx jscpd`** (у CI без **`--fix`** для oxlint/eslint — див. приклад workflow нижче). Без **prettier** і **@nitra/prettier-config**. У **`devDependencies`** має бути **`@nitra/eslint-config` мінімум `^3.8.0`** (з цієї версії правило `no-restricted-syntax` забороняє `for...in`; також транзитивно йде **`@e18e/eslint-plugin`** для oxlint); пакет **`@e18e/eslint-plugin`** окремо не додавай. Пакети oxlint/eslint/jscpd не додавай без потреби монорепо.
7
+ **oxlint**, **ESLint**, **jscpd**. У скрипті **`lint-js`** і в CI — **`bunx oxlint`**, **`bunx eslint`**, **`bunx jscpd`** (у CI без **`--fix`** для oxlint/eslint — див. приклад workflow нижче). Без **prettier** і **@nitra/prettier-config**. У **`devDependencies`** має бути **`@nitra/eslint-config` мінімум `^3.9.2`** (з **3.8.0** правило `no-restricted-syntax` забороняє `for...in`; з **3.9.2** у `getConfig` вбудовано ignore для **`**/adr/**`** — ADR-документи не валідуються ESLint, локально цей glob додавати не потрібно; також транзитивно йде **`@e18e/eslint-plugin`** для oxlint); пакет **`@e18e/eslint-plugin`** окремо не додавай. Пакети oxlint/eslint/jscpd не додавай без потреби монорепо.
8
8
 
9
9
  ```json title=".vscode/extensions.json"
10
10
  {
@@ -25,7 +25,7 @@ version: '1.16'
25
25
  "lint-js": "bunx oxlint --fix && bunx eslint --fix . && bunx jscpd ."
26
26
  },
27
27
  "devDependencies": {
28
- "@nitra/eslint-config": "^3.8.0"
28
+ "@nitra/eslint-config": "^3.9.2"
29
29
  }
30
30
  }
31
31
  ```
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.26'
3
+ version: '1.27'
4
4
  globs: "**/k8s/**/*.yaml"
5
5
  alwaysApply: false
6
6
  ---
@@ -318,7 +318,7 @@ images:
318
318
 
319
319
  **`check k8s` автоматично** для кожного `kustomization.yaml`:
320
320
 
321
- 1. конвертує JSON6902-патч `op: replace` на `/spec/template/spec/containers/<N>/image` (target `kind: Deployment`) у запис `images:` (резолвить оригінальний image у base через `resources:` / `bases` / `components` / `crds`); якщо в `patches[]` залишається лише ця операція — патч прибирається повністю;
321
+ 1. конвертує кожну JSON6902-операцію `op: replace` на `/spec/template/spec/containers/<N>/image` (target `kind: Deployment`) у запис `images:` (резолвить оригінальний image у base через `resources:` / `bases` / `components` / `crds`). Якщо у `patches[i].patch` після конвертації не залишилось ops — патч прибирається повністю; інакше у `patches[i].patch` залишаються лише не-image ops у вихідному порядку;
322
322
  2. чистить існуючий блок `images:` — зрізає `:tag` з `name` і видаляє `newTag`, який збігається з відрізаним тегом.
323
323
 
324
324
  ## Ingress → Gateway API (GKE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.201",
3
+ "version": "1.8.203",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -4,8 +4,9 @@
4
4
  * Канонічний `lint-js`, flat ESLint з getConfig і ignore для auto-imports, рекомендації VSCode,
5
5
  * `.oxlintrc.json` має збігатися з каноном oxlint у пакеті (`npm/scripts/utils/oxlint-canonical.json`):
6
6
  * plugins, jsPlugins, categories, усі правила з канону (додаткові записи в `rules` дозволені), settings, env,
7
- * globals, ignorePatterns. `@nitra/eslint-config` у devDependencies мінімум **3.8.0** (з цієї версії
8
- * правило `no-restricted-syntax` для `ForInStatement` забороняє `for...in`; також тягне транзитивний
7
+ * globals, ignorePatterns. `@nitra/eslint-config` у devDependencies мінімум **3.9.2** (з 3.8.0 правило
8
+ * `no-restricted-syntax` для `ForInStatement` забороняє `for...in`; з 3.9.2 у `getConfig` вбудовано
9
+ * ignore для ADR-каталогів — локально цей glob додавати не потрібно; також тягне транзитивний
9
10
  * `@e18e/eslint-plugin` для oxlint), `.jscpd.json` (gitignore, exitCode, reporters, minLines), workflow
10
11
  * `lint-js.yml` (checkout@v6, setup-bun-deps, bunx без --fix), без prettier, `engines.node` >= 24,
11
12
  * `engines.bun` >= 1.3, `"type": "module"` у кореневому і всіх workspace `package.json`. Дубль перевірки JS у `lint.yml` — заборонено.
@@ -54,10 +55,11 @@ export function isCanonicalLintJs(script) {
54
55
  }
55
56
 
56
57
  /**
57
- * Чи діапазон `@nitra/eslint-config` у `package.json` задовольняє мінімум `>= 3.8.0`
58
- * (заборона `for...in` через `no-restricted-syntax` + транзитивний `@e18e/eslint-plugin` для oxlint).
58
+ * Чи діапазон `@nitra/eslint-config` у `package.json` задовольняє мінімум `>= 3.9.2`
59
+ * (заборона `for...in` через `no-restricted-syntax` з 3.8.0 + вбудований ignore для ADR-каталогів
60
+ * у `getConfig` з 3.9.2 + транзитивний `@e18e/eslint-plugin` для oxlint).
59
61
  * @param {unknown} versionSpec значення `devDependencies['@nitra/eslint-config']`
60
- * @returns {boolean} true для `workspace:*` або першої semver у рядку >= 3.8.0
62
+ * @returns {boolean} true для `workspace:*` або першої semver у рядку >= 3.9.2
61
63
  */
62
64
  export function nitraEslintConfigMeetsMinVersion(versionSpec) {
63
65
  const s = String(versionSpec).trim()
@@ -72,7 +74,7 @@ export function nitraEslintConfigMeetsMinVersion(versionSpec) {
72
74
  if ([major, minor, patch].some(n => Number.isNaN(n))) {
73
75
  return false
74
76
  }
75
- return major > 3 || (major === 3 && minor >= 8 && patch >= 0)
77
+ return major > 3 || (major === 3 && (minor > 9 || (minor === 9 && patch >= 2)))
76
78
  }
77
79
 
78
80
  /**
@@ -269,11 +271,11 @@ function checkPackageJsonLintDeps(pkg, passFn, failFn) {
269
271
  passFn('@nitra/eslint-config є в devDependencies')
270
272
  if (nitraEslintConfigMeetsMinVersion(nitraEslint)) {
271
273
  passFn(
272
- '@nitra/eslint-config: мінімум 3.8.0 (no-restricted-syntax для ForInStatement + @e18e/eslint-plugin транзитивно, js-lint.mdc)'
274
+ '@nitra/eslint-config: мінімум 3.9.2 (no-restricted-syntax для ForInStatement з 3.8.0 + вбудований ignore "**/adr/**" з 3.9.2 + @e18e/eslint-plugin транзитивно, js-lint.mdc)'
273
275
  )
274
276
  } else {
275
277
  failFn(
276
- '@nitra/eslint-config: онови до мінімум "^3.8.0" — з цієї версії правило no-restricted-syntax забороняє for...in (плюс транзитивний @e18e/eslint-plugin для oxlint, js-lint.mdc)'
278
+ '@nitra/eslint-config: онови до мінімум "^3.9.2" — з 3.9.2 у getConfig вбудовано ignore для "**/adr/**" (ADR-документи не валідуються), плюс транзитивний @e18e/eslint-plugin для oxlint і заборона for...in з 3.8.0 (js-lint.mdc)'
277
279
  )
278
280
  }
279
281
  } else {
@@ -5626,10 +5626,14 @@ function applyNameStripTag(originalLine, parsed) {
5626
5626
  const KUSTOMIZATION_DEPLOYMENT_CONTAINER_IMAGE_PATH_RE = /^\/spec\/template\/spec\/containers\/(\d+)\/image$/u
5627
5627
 
5628
5628
  /**
5629
- * Якщо `patchObj` — JSON6902 з єдиною операцією `replace` на шляху image-контейнера у `Deployment`,
5630
- * повертає `{ deployName, containerIndex, newImage }`. Інакше null.
5629
+ * Якщо `patchObj` — JSON6902 для `kind: Deployment`, повертає всі image-replace ops
5630
+ * у його `patch:` разом із `opIndex` (позиція в масиві ops) і `totalOps` (загальна довжина).
5631
5631
  * @param {unknown} patchObj елемент масиву `patches[]`
5632
- * @returns {{ deployName: string, containerIndex: number, newImage: string } | null} інформація про патч або null, якщо це не очікувана JSON6902-операція
5632
+ * @returns {{
5633
+ * deployName: string,
5634
+ * totalOps: number,
5635
+ * ops: Array<{ containerIndex: number, newImage: string, opIndex: number }>
5636
+ * } | null} інформація про image-replace ops у патчі або null
5633
5637
  */
5634
5638
  export function imageReplaceDeploymentPatchInfo(patchObj) {
5635
5639
  const pr = asPlainObject(patchObj)
@@ -5638,20 +5642,21 @@ export function imageReplaceDeploymentPatchInfo(patchObj) {
5638
5642
  if (deployName === null) return null
5639
5643
  if (typeof pr.patch !== 'string') return null
5640
5644
 
5641
- const parsedArr = tryParseSingleJson6902Array(pr.patch)
5645
+ const parsedArr = tryParseJson6902Array(pr.patch)
5642
5646
  if (parsedArr === null) return null
5643
- const op = asPlainObject(parsedArr[0])
5644
- if (op === null) return null
5645
5647
 
5646
- const containerIndex = singleImageReplaceContainerIndex(op)
5647
- if (containerIndex === null) return null
5648
- if (typeof op.value !== 'string' || op.value.trim() === '') return null
5649
-
5650
- return {
5651
- deployName,
5652
- containerIndex,
5653
- newImage: op.value.trim()
5648
+ /** @type {Array<{ containerIndex: number, newImage: string, opIndex: number }>} */
5649
+ const ops = []
5650
+ for (let i = 0; i < parsedArr.length; i++) {
5651
+ const op = asPlainObject(parsedArr[i])
5652
+ if (op === null) continue
5653
+ const containerIndex = singleImageReplaceContainerIndex(op)
5654
+ if (containerIndex === null) continue
5655
+ if (typeof op.value !== 'string' || op.value.trim() === '') continue
5656
+ ops.push({ containerIndex, newImage: op.value.trim(), opIndex: i })
5654
5657
  }
5658
+ if (ops.length === 0) return null
5659
+ return { deployName, totalOps: parsedArr.length, ops }
5655
5660
  }
5656
5661
 
5657
5662
  /**
@@ -5679,11 +5684,11 @@ function deploymentTargetName(target) {
5679
5684
  }
5680
5685
 
5681
5686
  /**
5682
- * Парсить `patch`-рядок як YAML-масив з рівно однією операцією JSON6902.
5687
+ * Парсить `patch`-рядок як YAML-масив JSON6902-операцій (≥ 1 елемент).
5683
5688
  * @param {string} patch текст YAML-масиву JSON6902-операцій
5684
- * @returns {unknown[] | null} масив однієї операції або null
5689
+ * @returns {unknown[] | null} масив операцій або null
5685
5690
  */
5686
- function tryParseSingleJson6902Array(patch) {
5691
+ function tryParseJson6902Array(patch) {
5687
5692
  let parsedArr
5688
5693
  try {
5689
5694
  for (const d of parseAllDocuments(patch.trim())) {
@@ -5697,7 +5702,7 @@ function tryParseSingleJson6902Array(patch) {
5697
5702
  } catch {
5698
5703
  return null
5699
5704
  }
5700
- return Array.isArray(parsedArr) && parsedArr.length === 1 ? parsedArr : null
5705
+ return Array.isArray(parsedArr) && parsedArr.length >= 1 ? parsedArr : null
5701
5706
  }
5702
5707
 
5703
5708
  /**
@@ -5880,11 +5885,17 @@ export async function convertImagePatchesToImagesInKustomization(kustAbs, rootNo
5880
5885
 
5881
5886
  /**
5882
5887
  * Парсить kustomization.yaml як Document і повертає його разом зі списком кандидатів-патчів
5883
- * (`patches[i]` що відповідає `image-replace` для Deployment). Повертає null, якщо документ не
5884
- * розпарсився, не є Kustomization або не має масиву `patches:`.
5888
+ * (по одному кандидату на кожну image-replace op у `patches[i].patch` патч може містити кілька).
5889
+ * Повертає null, якщо документ не розпарсився, не є Kustomization або не має масиву `patches:`.
5885
5890
  * @param {string} raw текст файлу
5886
- * @returns {{ doc: ReturnType<typeof parseDocument>, candidates: Array<{ index: number, info: { deployName: string, containerIndex: number, newImage: string } }> } | null}
5887
- * document і список кандидатів, або null
5891
+ * @returns {{
5892
+ * doc: ReturnType<typeof parseDocument>,
5893
+ * candidates: Array<{
5894
+ * index: number,
5895
+ * totalOps: number,
5896
+ * info: { deployName: string, containerIndex: number, newImage: string, opIndex: number }
5897
+ * }>
5898
+ * } | null} document і список кандидатів, або null
5888
5899
  */
5889
5900
  function parseKustomizationWithPatches(raw) {
5890
5901
  let doc
@@ -5901,11 +5912,23 @@ function parseKustomizationWithPatches(raw) {
5901
5912
  if (typeof rec.apiVersion !== 'string' || !rec.apiVersion.startsWith(KUSTOMIZE_CONFIG_API_PREFIX)) return null
5902
5913
  if (!Array.isArray(rec.patches)) return null
5903
5914
 
5904
- /** @type {Array<{ index: number, info: { deployName: string, containerIndex: number, newImage: string } }>} */
5915
+ /** @type {Array<{ index: number, totalOps: number, info: { deployName: string, containerIndex: number, newImage: string, opIndex: number } }>} */
5905
5916
  const candidates = []
5906
5917
  for (const [i, p] of rec.patches.entries()) {
5907
5918
  const info = imageReplaceDeploymentPatchInfo(p)
5908
- if (info !== null) candidates.push({ index: i, info })
5919
+ if (info === null) continue
5920
+ for (const op of info.ops) {
5921
+ candidates.push({
5922
+ index: i,
5923
+ totalOps: info.totalOps,
5924
+ info: {
5925
+ deployName: info.deployName,
5926
+ containerIndex: op.containerIndex,
5927
+ newImage: op.newImage,
5928
+ opIndex: op.opIndex
5929
+ }
5930
+ })
5931
+ }
5909
5932
  }
5910
5933
  return { doc, candidates }
5911
5934
  }
@@ -5915,17 +5938,17 @@ function parseKustomizationWithPatches(raw) {
5915
5938
  * (або повідомлення про помилку, чому конвертація неможлива).
5916
5939
  * @param {string} kustAbs абсолютний шлях до kustomization.yaml
5917
5940
  * @param {string} rootNorm нормалізований корінь репо
5918
- * @param {Array<{ index: number, info: { deployName: string, containerIndex: number, newImage: string } }>} candidates кандидати з `patches[]`
5919
- * @returns {Promise<{ conversions: Array<{ index: number, name: string, newName: string, newTag: string | null }>, errors: string[] }>}
5941
+ * @param {Array<{ index: number, totalOps: number, info: { deployName: string, containerIndex: number, newImage: string, opIndex: number } }>} candidates кандидати з `patches[]`
5942
+ * @returns {Promise<{ conversions: Array<{ index: number, opIndex: number, totalOps: number, name: string, newName: string, newTag: string | null }>, errors: string[] }>}
5920
5943
  * результати конвертації та зібрані нефатальні помилки
5921
5944
  */
5922
5945
  async function buildPatchToImageConversions(kustAbs, rootNorm, candidates) {
5923
- /** @type {Array<{ index: number, name: string, newName: string, newTag: string | null }>} */
5946
+ /** @type {Array<{ index: number, opIndex: number, totalOps: number, name: string, newName: string, newTag: string | null }>} */
5924
5947
  const conversions = []
5925
5948
  /** @type {string[]} */
5926
5949
  const errors = []
5927
5950
 
5928
- for (const { index, info } of candidates) {
5951
+ for (const { index, totalOps, info } of candidates) {
5929
5952
  const baseImage = await walkKustomizationForDeploymentImage(
5930
5953
  kustAbs,
5931
5954
  rootNorm,
@@ -5934,7 +5957,7 @@ async function buildPatchToImageConversions(kustAbs, rootNorm, candidates) {
5934
5957
  new Set()
5935
5958
  )
5936
5959
  const conversion = buildConversionForCandidate(index, info, baseImage, errors)
5937
- if (conversion !== null) conversions.push(conversion)
5960
+ if (conversion !== null) conversions.push({ ...conversion, opIndex: info.opIndex, totalOps })
5938
5961
  }
5939
5962
 
5940
5963
  return { conversions, errors }
@@ -5944,7 +5967,7 @@ async function buildPatchToImageConversions(kustAbs, rootNorm, candidates) {
5944
5967
  * Будує одну конвертацію `patches[index]` → `images[]` запис з відповідним `newTag`.
5945
5968
  * Якщо щось не так (немає baseImage, digest у base/new) — додає текст у `errors` і повертає null.
5946
5969
  * @param {number} index індекс патча в `patches[]`
5947
- * @param {{ deployName: string, containerIndex: number, newImage: string }} info результат `imageReplaceDeploymentPatchInfo`
5970
+ * @param {{ deployName: string, containerIndex: number, newImage: string, opIndex: number }} info один із записів `imageReplaceDeploymentPatchInfo().ops` (плюс `deployName` патча)
5948
5971
  * @param {string | null} baseImage знайдений базовий image або null
5949
5972
  * @param {string[]} errors буфер нефатальних помилок (мутується)
5950
5973
  * @returns {{ index: number, name: string, newName: string, newTag: string | null } | null} запис конвертації або null
@@ -5975,19 +5998,43 @@ function buildConversionForCandidate(index, info, baseImage, errors) {
5975
5998
  }
5976
5999
 
5977
6000
  /**
5978
- * Видаляє конвертовані елементи з `patches:` (за потреби і сам ключ) і дописує `images:`.
6001
+ * Застосовує конвертації до Document: для кожного `patches[i]` або видаляє патч цілком (коли всі
6002
+ * його ops конвертовано), або переписує inline `patch:`, лишаючи решту ops без коментарів.
6003
+ * Допише `images:` з усіма конвертованими записами.
5979
6004
  * @param {ReturnType<typeof parseDocument>} doc документ kustomization.yaml
5980
- * @param {Array<{ index: number, name: string, newName: string, newTag: string | null }>} conversions конвертації
6005
+ * @param {Array<{ index: number, opIndex: number, totalOps: number, name: string, newName: string, newTag: string | null }>} conversions конвертації
5981
6006
  * @returns {boolean} true, якщо мутації відбулися (документ можна серіалізувати)
5982
6007
  */
5983
6008
  function applyConversionsToDoc(doc, conversions) {
5984
6009
  const patchesNode = doc.get('patches', true)
5985
6010
  if (!isSeq(patchesNode)) return false
5986
6011
 
5987
- const removeIdx = new Set(conversions.map(c => c.index))
5988
- for (let i = patchesNode.items.length - 1; i >= 0; i--) {
5989
- if (removeIdx.has(i)) patchesNode.delete(i)
6012
+ /** @type {Map<number, { totalOps: number, opIdx: number[] }>} */
6013
+ const byPatch = new Map()
6014
+ for (const c of conversions) {
6015
+ const slot = byPatch.get(c.index) ?? { totalOps: c.totalOps, opIdx: [] }
6016
+ slot.opIdx.push(c.opIndex)
6017
+ byPatch.set(c.index, slot)
6018
+ }
6019
+
6020
+ const sortedIdx = [...byPatch.keys()].sort((a, b) => b - a)
6021
+ for (const i of sortedIdx) {
6022
+ const slot = byPatch.get(i)
6023
+ if (slot === undefined) continue
6024
+ const { totalOps, opIdx } = slot
6025
+ if (opIdx.length === totalOps) {
6026
+ patchesNode.delete(i)
6027
+ continue
6028
+ }
6029
+ const patchEntry = patchesNode.get(i, true)
6030
+ if (patchEntry === undefined || patchEntry === null) continue
6031
+ const patchScalar = patchEntry.get('patch', true)
6032
+ if (patchScalar === undefined || patchScalar === null || typeof patchScalar.value !== 'string') continue
6033
+ const rewritten = rewriteInlinePatchWithoutOps(patchScalar.value, opIdx)
6034
+ if (rewritten === null) continue
6035
+ patchScalar.value = rewritten
5990
6036
  }
6037
+
5991
6038
  if (patchesNode.items.length === 0) {
5992
6039
  doc.delete('patches')
5993
6040
  }
@@ -6004,6 +6051,35 @@ function applyConversionsToDoc(doc, conversions) {
6004
6051
  return true
6005
6052
  }
6006
6053
 
6054
+ /**
6055
+ * Видаляє ops за списком індексів з inline `patch:` (текст YAML-масиву JSON6902-ops)
6056
+ * і повертає переписаний текст. Зберігає block-style. Повертає null, якщо не вдалося розпарсити
6057
+ * або після видалення не лишилось ops.
6058
+ * @param {string} patchText текст YAML-масиву ops (literal block scalar)
6059
+ * @param {number[]} opIndices індекси ops, які треба видалити
6060
+ * @returns {string | null} переписаний текст або null
6061
+ */
6062
+ function rewriteInlinePatchWithoutOps(patchText, opIndices) {
6063
+ let inner
6064
+ try {
6065
+ inner = parseDocument(patchText)
6066
+ } catch {
6067
+ return null
6068
+ }
6069
+ if (inner.errors.length > 0) return null
6070
+ const seq = inner.contents
6071
+ if (!isSeq(seq)) return null
6072
+
6073
+ const toRemove = [...new Set(opIndices)].sort((a, b) => b - a)
6074
+ for (const i of toRemove) {
6075
+ if (i < 0 || i >= seq.items.length) return null
6076
+ seq.delete(i)
6077
+ }
6078
+ if (seq.items.length === 0) return null
6079
+ seq.flow = false
6080
+ return inner.toString().replace(/\n+$/u, '')
6081
+ }
6082
+
6007
6083
  /**
6008
6084
  * Прохід для всіх `kustomization.yaml`: конвертує image-replace patches у `images:`,
6009
6085
  * потім чистить `images:` (зрізає теги в `name`, видаляє надлишкові `newTag`).