@nitra/cursor 1.8.151 → 1.8.153

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.
@@ -18,6 +18,14 @@ The primary development rules are stored in the Cursor rules directory:
18
18
  {{name}}
19
19
  {{/skills}}
20
20
 
21
+ ## Commands
22
+
23
+ Generated from the root `package.json` on each `npx @nitra/cursor` sync. Prefer `bun run <script>` for project scripts.
24
+
25
+ {{#commands}}
26
+ {{name}}
27
+ {{/commands}}
28
+
21
29
  ## Instructions for all agents
22
30
 
23
31
  Before making changes, read the relevant rule files for the area you are working on.
package/README.md CHANGED
@@ -63,7 +63,7 @@ CLI автоматично (команда завантаження правил
63
63
  1. Знайде або створить `.n-cursor.json` у поточній директорії (із полем `$schema` на JSON Schema пакету; якщо файл уже є без коректного `$schema`, поле буде додано або оновлено при зчитуванні конфігу)
64
64
  2. Створить директорію `.cursor/rules/`, якщо її ще немає
65
65
  3. Скопіює кожне з перелічених у конфігу правило з `mdc/` установленого пакету і збереже файли з префіксом `n-`
66
- 4. Після оновлення файлів на диску згенерує в корені проєкту **`AGENTS.md`**: повний вміст береться з шаблону пакету `AGENTS.template.md`, а список правил у шаблоні формується з **усіх наявних файлів `*.mdc`** у `.cursor/rules/` (відсортовано за ім’ям)
66
+ 4. Після оновлення файлів на диску згенерує в корені проєкту **`AGENTS.md`**: повний вміст береться з шаблону пакету `AGENTS.template.md`, а список правил у шаблоні формується з **усіх наявних файлів `*.mdc`** у `.cursor/rules/` (відсортовано за ім’ям); секція команд — з **`package.json`** кореня (див. `{{#commands}}` у шаблоні).
67
67
 
68
68
  ## Приклад виводу
69
69
 
@@ -114,13 +114,18 @@ npm/
114
114
 
115
115
  Під час запуску CLI тіло між `{{#services}}` і `{{/services}}` повторюється для кожного `*.mdc` у `.cursor/rules/`; у `{{name}}` підставляється вже готовий markdown-рядок (наприклад `- .cursor/rules/n-text.mdc`).
116
116
 
117
- 3. Після змін у шаблоні перевірте локально: у тестовому репозиторії з `.n-cursor.json` виконайте `npx`/`bunx` на зібраному пакеті або `node npm/bin/n-cursor.js` з кореня того репозиторію і переконайтеся, що **`AGENTS.md`** виглядає як очікується.
117
+ 3. Для секції **Skills** використовуйте блок **`{{#skills}}` `{{/skills}}`** з тим самим `{{name}}`: рядки формуються з каталогів у `.cursor/skills/` (див. також `buildSkillBulletItems` у `bin/n-cursor.js`).
118
+
119
+ 4. Для секції **Commands** використовуйте **`{{#commands}}` … `{{/commands}}`**: список генерується з кореневого **`package.json`** (поле `scripts` — відомі ключі у фіксованому порядку, плюс додаткові `lint-*`) та завжди доповнюється рядками про **`npx @nitra/cursor`** і **`npx @nitra/cursor check`**. Логіка винесена в **`npm/scripts/build-agents-commands.mjs`**.
120
+
121
+ 5. Після змін у шаблоні перевірте локально: у тестовому репозиторії з `.n-cursor.json` виконайте `npx`/`bunx` на зібраному пакеті або `node npm/bin/n-cursor.js` з кореня того репозиторію і переконайтеся, що **`AGENTS.md`** виглядає як очікується.
118
122
 
119
123
  ### Логіка в коді CLI
120
124
 
121
125
  - Шлях до шаблону: поруч із `mdc/`, тобто `…/node_modules/@nitra/cursor/AGENTS.template.md` після встановлення пакету.
122
126
  - Оновлення **`AGENTS.md`** виконується **після** циклу завантаження правил, щоб список відображав актуальний вміст `.cursor/rules/` на диску.
123
127
  - Якщо каталогу `.cursor/rules/` немає або в ньому немає `*.mdc`, блок `{{#services}}` стає порожнім; решта шаблону все одно записується в **`AGENTS.md`**.
128
+ - Секція **`commands`** залежить лише від **`package.json` у корені cwd**; якщо файлу немає або `scripts` відсутній, у блоці лишаються мінімальні рядки (`bun i`, виклики CLI).
124
129
 
125
130
  ## Мета проекту
126
131
 
package/bin/n-cursor.js CHANGED
@@ -18,6 +18,7 @@
18
18
  *
19
19
  * Файл AGENTS.md у корені: щоразу повністю перезаписується змістом з AGENTS.template.md
20
20
  * пакету; список правил у шаблоні будується з файлів *.mdc у .cursor/rules поточного проєкту.
21
+ * Секція команд — з кореневого package.json (scripts) та фіксовані рядки про CLI синхрону/перевірок.
21
22
  *
22
23
  * Після завантаження: у .cursor/rules видаляються файли *.mdc з префіксом «n-» (керовані
23
24
  * пакетом), яких немає у списку rules у .n-cursor.json. Інші .mdc у цій директорії залишаються.
@@ -45,6 +46,7 @@ import { basename, dirname, join } from 'node:path'
45
46
  import { cwd } from 'node:process'
46
47
  import { fileURLToPath } from 'node:url'
47
48
 
49
+ import { buildAgentsCommandBulletItems } from '../scripts/build-agents-commands.mjs'
48
50
  import { detectAutoRulesAndSkills, mergeConfigWithAutoDetected, normalizeIdList } from '../scripts/auto-rules.mjs'
49
51
  import { ensureNitraCursorInRootDevDependencies } from '../scripts/ensure-nitra-cursor-dev-dependencies.mjs'
50
52
  import { upgradeNitraCursorToLatestAndBunInstall } from '../scripts/upgrade-nitra-cursor-and-install.mjs'
@@ -424,19 +426,21 @@ function expandMustacheSection(template, section, items, prop) {
424
426
  }
425
427
 
426
428
  /**
427
- * Підставляє у вміст AGENTS.template.md список шляхів до файлів правил і skills
429
+ * Підставляє у вміст AGENTS.template.md список шляхів до файлів правил, skills і команд з package.json
428
430
  * @param {string} templateText вміст AGENTS.template.md
429
431
  * @param {string[]} mdcBasenames імена файлів (*.mdc) з .cursor/rules
430
432
  * @param {{ name: string }[]} skillItems рядки для секції Skills
433
+ * @param {{ name: string }[]} commandItems рядки для секції commands
431
434
  * @returns {string} готовий markdown для AGENTS.md
432
435
  */
433
- function renderAgentsTemplate(templateText, mdcBasenames, skillItems) {
436
+ function renderAgentsTemplate(templateText, mdcBasenames, skillItems, commandItems) {
434
437
  let result = templateText
435
438
  const serviceItems = mdcBasenames.map(mdcName => ({
436
439
  name: `- ${RULES_DIR}/${mdcName}`
437
440
  }))
438
441
  result = expandMustacheSection(result, 'services', serviceItems, 'name')
439
442
  result = expandMustacheSection(result, 'skills', skillItems, 'name')
443
+ result = expandMustacheSection(result, 'commands', commandItems, 'name')
440
444
  return result
441
445
  }
442
446
 
@@ -650,7 +654,8 @@ async function syncAgentsMd(agentsTemplatePath = BUNDLED_AGENTS_TEMPLATE_PATH) {
650
654
  const templateText = await readFile(agentsTemplatePath, 'utf8')
651
655
  const mdcFiles = await listProjectRulesMdcFiles()
652
656
  const skillItems = await buildSkillBulletItems()
653
- const body = renderAgentsTemplate(templateText, mdcFiles, skillItems)
657
+ const commandItems = await buildAgentsCommandBulletItems(cwd())
658
+ const body = renderAgentsTemplate(templateText, mdcFiles, skillItems, commandItems)
654
659
  const agentsPath = join(cwd(), AGENTS_FILE)
655
660
  const hadFile = existsSync(agentsPath)
656
661
  const out = body.endsWith('\n') ? body : `${body}\n`
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.151",
3
+ "version": "1.8.153",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Формує markdown-рядки для секції «Команди» у AGENTS.md.
3
+ *
4
+ * Джерело істини — `package.json` у корені цільового репозиторію: з поля `scripts` беруться відомі ключі
5
+ * у стабільному порядку, додатково — усі `lint-*`, яких не було в основному списку.
6
+ *
7
+ * Наприкінці завжди додаються рядки про CLI `@nitra/cursor` (синхрон правил / programmatic check),
8
+ * на початку — рекомендована команда `bun i` за конвенціями monorepo.
9
+ */
10
+ import { existsSync } from 'node:fs'
11
+ import { readFile } from 'node:fs/promises'
12
+ import { join } from 'node:path'
13
+
14
+ const PACKAGE_NAME = '@nitra/cursor'
15
+ const AGENTS_MD = 'AGENTS.md'
16
+
17
+ /** Порядок виводу скриптів із `package.json` (лише ті, що реально існують). */
18
+ const SCRIPT_KEYS_ORDER = /** @type {const} */ ([
19
+ 'test',
20
+ 'lint',
21
+ 'lint-js',
22
+ 'lint-text',
23
+ 'lint-ga',
24
+ 'lint-k8s',
25
+ 'lint-docker',
26
+ 'start',
27
+ 'dev',
28
+ 'build'
29
+ ])
30
+
31
+ /**
32
+ * Зчитує `scripts` з `package.json` у `projectRoot` або повертає порожній об'єкт.
33
+ * @param {string} projectRoot абсолютний шлях до кореня репозиторію
34
+ * @returns {Promise<Record<string, string>>} об'єкт скриптів
35
+ */
36
+ async function readPackageScripts(projectRoot) {
37
+ const pkgPath = join(projectRoot, 'package.json')
38
+ if (!existsSync(pkgPath)) {
39
+ return {}
40
+ }
41
+ try {
42
+ const raw = await readFile(pkgPath, 'utf8')
43
+ const pkg = JSON.parse(raw)
44
+ if (pkg && typeof pkg === 'object' && pkg.scripts && typeof pkg.scripts === 'object') {
45
+ return /** @type {Record<string, string>} */ (pkg.scripts)
46
+ }
47
+ } catch {
48
+ // некоректний JSON або IO — секція команд лишиться з мінімумом (bun i + npx)
49
+ }
50
+ return {}
51
+ }
52
+
53
+ /**
54
+ * Повертає елементи для Mustache-секції `commands` у AGENTS.template.md.
55
+ * @param {string} projectRoot абсолютний шлях до кореня репозиторію (зазвичай `process.cwd()`)
56
+ * @returns {Promise<{ name: string }[]>} рядки з полем `name` для `expandMustacheSection`
57
+ */
58
+ export async function buildAgentsCommandBulletItems(projectRoot) {
59
+ const scripts = await readPackageScripts(projectRoot)
60
+ const items = /** @type {{ name: string }[]} */ ([])
61
+
62
+ items.push({ name: `- **Залежності**: \`bun i\`` })
63
+
64
+ const added = new Set()
65
+
66
+ for (const key of SCRIPT_KEYS_ORDER) {
67
+ if (typeof scripts[key] === 'string' && scripts[key].length > 0) {
68
+ items.push({ name: `- **${key}**: \`bun run ${key}\`` })
69
+ added.add(key)
70
+ }
71
+ }
72
+
73
+ const lintExtraKeys = Object.keys(scripts)
74
+ .filter(k => k.startsWith('lint-') && !added.has(k) && typeof scripts[k] === 'string')
75
+ .toSorted((a, b) => a.localeCompare(b))
76
+
77
+ for (const key of lintExtraKeys) {
78
+ items.push({ name: `- **${key}**: \`bun run ${key}\`` })
79
+ added.add(key)
80
+ }
81
+
82
+ items.push({
83
+ name: `- **Оновити правила та ${AGENTS_MD}** (після змін у правилах/шаблоні CLI): \`npx ${PACKAGE_NAME}\``
84
+ })
85
+ items.push({ name: `- **Перевірки правил (programmatic)**: \`npx ${PACKAGE_NAME} check\`` })
86
+
87
+ return items
88
+ }
@@ -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)