@nitra/cursor 1.8.124 → 1.8.125

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mdc/k8s.mdc CHANGED
@@ -294,6 +294,10 @@ data:
294
294
  - **`pdb.yaml`** — `policy/v1`, `PodDisruptionBudget`, `spec.selector.matchLabels.app` **= `spec.selector.matchLabels.app`** Deployment.
295
295
  - **`topologySpreadConstraints`** — запис з `maxSkew: 1`, `topologyKey: kubernetes.io/hostname`, `whenUnsatisfiable: ScheduleAnyway`, `labelSelector.matchLabels.app` рівне тій самій мітці `app`.
296
296
 
297
+ **Kustomize `base` і overlay:** у дереві, зібраному з `k8s/…/base/kustomization.yaml` (`resources` / `bases` / `components` / `crds`, рекурсивно), **HorizontalPodAutoscaler** і **PodDisruptionBudget** допустимі **лише якщо** в тому ж дереві kustomize є **Deployment**. У `kustomization.yaml` overlay, який підключає цей `base` (наприклад, `../base` у `resources`), не додавай окремі YAML-файли з HPA / PDB, доки в наслідуваному `base` у дереві не з’явиться `Deployment`. Перевіряє **`check-k8s.mjs`**.
298
+
299
+ **Локальні шляхи в `kustomization.yaml`:** кожен запис без `://` (remote) з `resources` / `bases` / `components` / `crds`, `patchesStrategicMerge`, `patches[].path`, `patchesJson6902[].path`, `configurations[]`, `replacements[].path` має вказувати на **існуючий** у репозиторії файл (`.yaml` / `.yml`) або **каталог**; биті посилання — помилка **`check k8s`**.
300
+
297
301
  ### Env-залежні межі (за сегментом після `/k8s/`)
298
302
 
299
303
  **Dev-like середовища** — сегмент `base`, `dev`, або з суфіксом `-qa` (напр. `tr-qa`):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.124",
3
+ "version": "1.8.125",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -79,6 +79,17 @@
79
79
  * з (`>=2`, `>=2`, `>=1`) не залишалися на dev-значеннях із base. Формат patch — JSON6902 або Strategic Merge;
80
80
  * наявність перевіряється через `kustomizationPatchPathsByTargetKind` (конкретне значення — у вмісті patch,
81
81
  * яке буде оцінено під час збірки Kustomize).
82
+ *
83
+ * **Існування шляхів у `kustomization.yaml`:** кожне локальне посилання (без `://`) з `resources` / `bases` /
84
+ * `components` / `crds`, `patchesStrategicMerge`, `patches[].path`, `patchesJson6902[].path`, `configurations[]`,
85
+ * `replacements[].path` має вказувати на наявний у репозиторії файл (`.yaml` / `.yml`) або каталог; інакше
86
+ * помилка `check k8s` (k8s.mdc).
87
+ *
88
+ * **HPA / PDB тільки з Deployment у `base`:** у дереві Kustomize з `…/k8s/…/base/kustomization.yaml` не
89
+ * дозволяти `HorizontalPodAutoscaler` / `PodDisruptionBudget` у `resources` / `bases` / `components` / `crds`
90
+ * (рекурсивно), якщо в цьому ж дереві немає `Deployment`. У `kustomization.yaml` overlay, який підключає
91
+ * каталог `…/k8s/…/base`, не додавай окремі YAML-файли з HPA / PDB, поки в наслідуваному `base` у дереві
92
+ * не з’явиться `Deployment` (k8s.mdc).
82
93
  */
83
94
  import { existsSync } from 'node:fs'
84
95
  import { readFile, readdir, stat, unlink } from 'node:fs/promises'
@@ -386,6 +397,122 @@ function pathsFromKustomizationObject(obj) {
386
397
  return out
387
398
  }
388
399
 
400
+ /**
401
+ * Унікальні локальні шляхи з `kustomization.yaml` для перевірки існування на диску:
402
+ * як у `pathsFromKustomizationObject`, плюс **`patchesJson6902[].path`**, плюс **`configurations[]`**
403
+ * (рядки-шляхи) і **`replacements[].path`**, якщо задано.
404
+ * @param {unknown} obj корінь першого документа
405
+ * @returns {string[]}
406
+ */
407
+ export function kustomizePathRefsForExistenceCheck(obj) {
408
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
409
+ return []
410
+ }
411
+ const fromPaths = pathsFromKustomizationObject(obj)
412
+ const rec = /** @type {Record<string, unknown>} */ (obj)
413
+ const pj = rec.patchesJson6902
414
+ if (Array.isArray(pj)) {
415
+ for (const item of pj) {
416
+ if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
417
+ const pth = /** @type {Record<string, unknown>} */ (item).path
418
+ if (typeof pth === 'string' && pth.trim() !== '') {
419
+ fromPaths.push(pth.trim())
420
+ }
421
+ }
422
+ }
423
+ }
424
+ const configurations = rec.configurations
425
+ if (Array.isArray(configurations)) {
426
+ for (const c of configurations) {
427
+ if (typeof c === 'string' && c.trim() !== '') {
428
+ fromPaths.push(c.trim())
429
+ }
430
+ }
431
+ }
432
+ const replacements = rec.replacements
433
+ if (Array.isArray(replacements)) {
434
+ for (const r of replacements) {
435
+ if (r !== null && typeof r === 'object' && !Array.isArray(r)) {
436
+ const pth = /** @type {Record<string, unknown>} */ (r).path
437
+ if (typeof pth === 'string' && pth.trim() !== '') {
438
+ fromPaths.push(pth.trim())
439
+ }
440
+ }
441
+ }
442
+ }
443
+ return [...new Set(fromPaths)]
444
+ }
445
+
446
+ /**
447
+ * Перевіряє, що всі перелічені в `kustomization.yaml` локальні шляхи існують.
448
+ * @param {string} root корінь репо
449
+ * @param {string} kustAbs kustomization.yaml
450
+ * @param {string} rootNorm нормалізований корінь
451
+ * @param {(msg: string) => void} fail callback
452
+ * @returns {Promise<void>}
453
+ */
454
+ async function validateOneKustomizationPathRefsExist(root, kustAbs, rootNorm, fail) {
455
+ const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
456
+ const kust = await readFirstYamlObject(kustAbs)
457
+ if (kust === null) {
458
+ return
459
+ }
460
+ if (kust.kind !== 'Kustomization') {
461
+ return
462
+ }
463
+ const refs = kustomizePathRefsForExistenceCheck(kust)
464
+ const kustDir = dirname(resolve(kustAbs))
465
+ for (const r of refs) {
466
+ if (typeof r !== 'string' || r.includes('://') || r.trim() === '') {
467
+ continue
468
+ }
469
+ const target = resolve(kustDir, r.trim())
470
+ if (!resolvedFilePathIsUnderRoot(rootNorm, target)) {
471
+ fail(
472
+ `${rel}: посилання «${r}» виходить за межі репозиторію (resolve: ${(relative(rootNorm, target) || target).replaceAll('\\', '/')
473
+ }) (k8s.mdc)`
474
+ )
475
+ continue
476
+ }
477
+ /** @type {import('node:fs').Stats | undefined} */
478
+ let st
479
+ try {
480
+ st = await stat(target)
481
+ } catch {
482
+ st = undefined
483
+ }
484
+ if (st === undefined) {
485
+ fail(
486
+ `${rel}: посилання «${r}» вказує на неіснуючий ресурс (очікувано файл або каталог; k8s.mdc)`
487
+ )
488
+ continue
489
+ }
490
+ if (st.isFile()) {
491
+ if (!YAML_EXTENSION_RE.test(target)) {
492
+ fail(
493
+ `${rel}: «${r}» — за правилами k8s у kustomization для файлів дозволені лише розширення .yaml / .yml (k8s.mdc)`
494
+ )
495
+ }
496
+ } else if (!st.isDirectory()) {
497
+ fail(`${rel}: «${r}» — ні файл, ні каталог (k8s.mdc)`)
498
+ }
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Усі `kustomization.yaml` під `k8s`: локальні `path` / ресурси мають існувати.
504
+ * @param {string} root
505
+ * @param {string[]} yamlFilesAbs
506
+ * @param {(msg: string) => void} fail
507
+ * @returns {Promise<void>}
508
+ */
509
+ async function validateKustomizationPathRefsExistOnDisk(root, yamlFilesAbs, fail) {
510
+ const rootNorm = resolve(root)
511
+ for (const kustAbs of yamlFilesAbs.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')) {
512
+ await validateOneKustomizationPathRefsExist(root, kustAbs, rootNorm, fail)
513
+ }
514
+ }
515
+
389
516
  /**
390
517
  * Чи для кожного посилання kustomization на файл **`svc.yaml`** у списку є посилання на sibling **`svc-hl.yaml`**
391
518
  * (той самий каталог після **`resolve`** відносно каталогу **`kustomization.yaml`**).
@@ -4107,6 +4234,287 @@ async function readFirstYamlObject(absPath) {
4107
4234
  return null
4108
4235
  }
4109
4236
 
4237
+ /**
4238
+ * Чи відносний шлях вказує на `k8s/…/base/kustomization.yaml` (каталог `base` у дереві k8s).
4239
+ * @param {string} rel POSIX-шлях
4240
+ * @returns {boolean} true, якщо батьківський каталог — `…/…/base` у шляху з `k8s`
4241
+ */
4242
+ function isK8sBaseKustomizationRelPath(rel) {
4243
+ const n = rel.replaceAll('\\', '/')
4244
+ const d = dirname(n).replaceAll('\\', '/')
4245
+ if (basename(d) !== 'base') {
4246
+ return false
4247
+ }
4248
+ return d.startsWith('k8s/') || d.includes('/k8s/')
4249
+ }
4250
+
4251
+ /**
4252
+ * Чи абсолютний шлях до каталогу — k8s-`base` (ідентифікуємо за тим, що `relative` від кореня
4253
+ * містить сегмент `k8s` і basename каталогу — `base`).
4254
+ * @param {string} rootNorm нормалізований корінь репо
4255
+ * @param {string} dirAbs абсолютний шлях до каталогу
4256
+ * @returns {boolean} true для `.../k8s/.../base` з `kustomization.yaml` у цьому каталозі
4257
+ */
4258
+ function isUnderK8sPathRelToRoot(rootNorm, dirAbs) {
4259
+ const rel = (relative(rootNorm, dirAbs) || '.').replaceAll('\\', '/')
4260
+ if (rel === '' || rel === '.') {
4261
+ return false
4262
+ }
4263
+ if (rel.startsWith('../') || rel === '..') {
4264
+ return false
4265
+ }
4266
+ return rel === 'k8s' || rel.startsWith('k8s/') || rel.includes('/k8s/')
4267
+ }
4268
+
4269
+ /**
4270
+ * Чи файловий шлях усередині `dirAbs` (або збігається).
4271
+ * @param {string} dirAbs каталог
4272
+ * @param {string} fileAbs файл
4273
+ * @returns {boolean} true, якщо файл — піддерево каталогу
4274
+ */
4275
+ function isResolvedFileUnderDirectory(dirAbs, fileAbs) {
4276
+ const b = resolve(dirAbs)
4277
+ const f = resolve(fileAbs)
4278
+ const r = relative(b, f).replaceAll('\\', '/')
4279
+ if (r === '' || r === '.') {
4280
+ return true
4281
+ }
4282
+ return !r.startsWith('../') && r !== '..'
4283
+ }
4284
+
4285
+ /**
4286
+ * За списку посилань kustomize повертає каталоги `.../base` з `kustomization.yaml` (наслідування base).
4287
+ * @param {string} kustDir каталог kustomization.yaml
4288
+ * @param {string[]} pathRefs тільки resources / bases / components / crds
4289
+ * @param {string} rootNorm нормалізований корінь репо
4290
+ * @returns {Promise<string[]>} абсолютні шляхи (без дедуплікації, якщо кілька однакових ref)
4291
+ */
4292
+ async function k8sBaseDirsFromKustomizeResourcePathRefs(kustDir, pathRefs, rootNorm) {
4293
+ /** @type {string[]} */
4294
+ const out = []
4295
+ for (const ref of pathRefs) {
4296
+ if (typeof ref !== 'string' || ref.includes('://') || ref.trim() === '') {
4297
+ continue
4298
+ }
4299
+ const resolved = resolve(kustDir, ref.trim())
4300
+ if (!resolvedFilePathIsUnderRoot(rootNorm, resolved)) {
4301
+ continue
4302
+ }
4303
+ let st
4304
+ try {
4305
+ st = await stat(resolved)
4306
+ } catch {
4307
+ st = undefined
4308
+ }
4309
+ if (st === undefined || !st.isDirectory() || basename(resolved) !== 'base') {
4310
+ continue
4311
+ }
4312
+ if (!existsSync(join(resolved, 'kustomization.yaml')) || !isUnderK8sPathRelToRoot(rootNorm, resolved)) {
4313
+ continue
4314
+ }
4315
+ out.push(resolved)
4316
+ }
4317
+ return out
4318
+ }
4319
+
4320
+ /**
4321
+ * Аналізує `resources` / `bases` / `components` / `crds` kustomization: чи в дереві є
4322
+ * `Deployment` / HPA / PDB.
4323
+ * @param {string} kustAbs kustomization.yaml
4324
+ * @param {string} rootNorm корінь
4325
+ * @returns {Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} прапорці
4326
+ */
4327
+ export async function kustomizeResourceTreeHpaPdbDeploymentFlags(kustAbs, rootNorm) {
4328
+ /** @type {Set<string>} */
4329
+ const visitedKustomization = new Set()
4330
+ const desc = await collectResourceDescriptorsForKustomizationWalk(kustAbs, rootNorm, visitedKustomization)
4331
+ return {
4332
+ hasDeployment: desc.some(d => d.kind === 'Deployment'),
4333
+ hasHpa: desc.some(d => d.kind === 'HorizontalPodAutoscaler'),
4334
+ hasPdb: desc.some(d => d.kind === 'PodDisruptionBudget')
4335
+ }
4336
+ }
4337
+
4338
+ /**
4339
+ * Чи серед документів YAML-файлу є `HorizontalPodAutoscaler` або `PodDisruptionBudget`.
4340
+ * @param {string} fileAbs абсолютний шлях
4341
+ * @returns {Promise<boolean>} true, якщо такі kind знайдені
4342
+ */
4343
+ async function yamlFileContainsHpaOrPdbDocument(fileAbs) {
4344
+ const raw = await tryReadFileUtf8(fileAbs)
4345
+ if (raw === undefined) {
4346
+ return false
4347
+ }
4348
+ const docs = tryParseAllYamlDocs(raw)
4349
+ if (docs === undefined) {
4350
+ return false
4351
+ }
4352
+ for (const doc of docs) {
4353
+ if (doc.errors.length > 0) {
4354
+ continue
4355
+ }
4356
+ const o = doc.toJSON()
4357
+ if (o === null || typeof o !== 'object' || Array.isArray(o)) {
4358
+ continue
4359
+ }
4360
+ const k = /** @type {Record<string, unknown>} */ (o).kind
4361
+ if (k === 'HorizontalPodAutoscaler' || k === 'PodDisruptionBudget') {
4362
+ return true
4363
+ }
4364
+ }
4365
+ return false
4366
+ }
4367
+
4368
+ /**
4369
+ * Для `…/k8s/…/base/kustomization.yaml`: HPA / PDB дозволені в дереві kustomize лише разом із Deployment.
4370
+ * @param {string} kustAbs kustomization.yaml
4371
+ * @param {string} rel для повідомлень
4372
+ * @param {(msg: string) => void} fail callback
4373
+ * @param {(msg: string) => void} passFn success
4374
+ * @param {(kust: string) => Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} getTreeFlags мемоізований аналіз дерева
4375
+ * @returns {Promise<void>}
4376
+ */
4377
+ async function verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, passFn, getTreeFlags) {
4378
+ const { hasDeployment, hasHpa, hasPdb } = await getTreeFlags(kustAbs)
4379
+ if (hasHpa || hasPdb) {
4380
+ if (hasDeployment) {
4381
+ passFn(
4382
+ `${rel}: у дереві kustomize base є HPA/PDB і Deployment (k8s.mdc)`
4383
+ )
4384
+ } else {
4385
+ fail(
4386
+ `${rel}: у base є HorizontalPodAutoscaler і/або PodDisruptionBudget у resources/bases/…, але дерева kustomize не містить Deployment — HPA і PDB дозволені тільки разом із Deployment (k8s.mdc)`
4387
+ )
4388
+ }
4389
+ }
4390
+ }
4391
+
4392
+ /**
4393
+ * `kustomization` overlay, що посилається на `…/k8s/…/base`, не може додавати HPA / PDB як окремі YAML,
4394
+ * поки в наслідуваному base немає Deployment.
4395
+ * @param {string} root нормалізований корінь репо
4396
+ * @param {string} kustAbs kustomization.yaml
4397
+ * @param {string} rel для повідомлень
4398
+ * @param {Record<string, unknown>} kustObj перший документ
4399
+ * @param {(msg: string) => void} fail callback
4400
+ * @param {(msg: string) => void} passFn success
4401
+ * @param {(kust: string) => Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} getTreeFlags
4402
+ * @returns {Promise<void>}
4403
+ */
4404
+ async function verifyOverlayHpaPdbFileRefsRespectBaseDeployment(
4405
+ root,
4406
+ kustAbs,
4407
+ rel,
4408
+ kustObj,
4409
+ fail,
4410
+ passFn,
4411
+ getTreeFlags
4412
+ ) {
4413
+ const kustDir = dirname(kustAbs)
4414
+ const pathRefs = resourcePathRefsFromKustomizationObject(kustObj)
4415
+ const baseDirs = await k8sBaseDirsFromKustomizeResourcePathRefs(kustDir, pathRefs, root)
4416
+ if (baseDirs.length === 0) {
4417
+ return
4418
+ }
4419
+
4420
+ const anyBaseHasDep = await (async () => {
4421
+ for (const baseDir of baseDirs) {
4422
+ const { hasDeployment: h } = await getTreeFlags(join(baseDir, 'kustomization.yaml'))
4423
+ if (h) {
4424
+ return true
4425
+ }
4426
+ }
4427
+ return false
4428
+ })()
4429
+ for (const ref of pathRefs) {
4430
+ if (typeof ref !== 'string' || ref.includes('://') || ref.trim() === '') {
4431
+ continue
4432
+ }
4433
+ const fAbs = resolve(kustDir, ref.trim())
4434
+ if (!resolvedFilePathIsUnderRoot(root, fAbs) || !existsSync(fAbs)) {
4435
+ continue
4436
+ }
4437
+ let st
4438
+ try {
4439
+ st = await stat(fAbs)
4440
+ } catch {
4441
+ st = undefined
4442
+ }
4443
+ if (st === undefined) {
4444
+ continue
4445
+ }
4446
+ if (!st.isFile() || !YAML_EXTENSION_RE.test(fAbs)) {
4447
+ continue
4448
+ }
4449
+ const fUnderSomeBase = baseDirs.some(bd => isResolvedFileUnderDirectory(bd, fAbs))
4450
+ if (fUnderSomeBase) {
4451
+ continue
4452
+ }
4453
+ const hpaPdb = await yamlFileContainsHpaOrPdbDocument(fAbs)
4454
+ if (!hpaPdb) {
4455
+ continue
4456
+ }
4457
+ if (!anyBaseHasDep) {
4458
+ fail(
4459
+ `${rel}: посилання «${ref}» містить HorizontalPodAutoscaler і/або PodDisruptionBudget, а наслідуваний k8s/base не дає у дереві Deployment — прибери HPA/PDB або додай Deployment у base (k8s.mdc)`
4460
+ )
4461
+ } else {
4462
+ passFn(
4463
+ `${rel}: overlay-файл «${(relative(root, fAbs) || ref).replaceAll('\\', '/')}» з HPA/PDB, base містить Deployment (k8s.mdc)`
4464
+ )
4465
+ }
4466
+ }
4467
+ }
4468
+
4469
+ /**
4470
+ * Перевіряє всі кастомізації: (1) у k8s/base дереві HPA/PDB тільки з Deployment; (2) overlay, що
4471
+ * посилається на base, не додає HPA/PDB без Deployment у base.
4472
+ * @param {string} root корінь репо
4473
+ * @param {string[]} yamlFilesAbs yaml у k8s
4474
+ * @param {(msg: string) => void} fail callback
4475
+ * @param {(msg: string) => void} passFn pass
4476
+ * @returns {Promise<void>}
4477
+ */
4478
+ async function validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFilesAbs, fail, passFn) {
4479
+ const rootNorm = resolve(root)
4480
+ /** @type {Map<string, Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>>} */
4481
+ const treeFlagsMemo = new Map()
4482
+ /**
4483
+ * @param {string} kustPath
4484
+ * @returns {Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>}
4485
+ */
4486
+ const getTreeFlags = kustPath => {
4487
+ const k = resolve(kustPath)
4488
+ let p = treeFlagsMemo.get(k)
4489
+ if (p === undefined) {
4490
+ p = kustomizeResourceTreeHpaPdbDeploymentFlags(k, rootNorm)
4491
+ treeFlagsMemo.set(k, p)
4492
+ }
4493
+ return p
4494
+ }
4495
+ const kustFiles = yamlFilesAbs.filter(abs => basename(abs).toLowerCase() === 'kustomization.yaml')
4496
+ for (const kustAbs of kustFiles) {
4497
+ const rel = (relative(rootNorm, kustAbs) || kustAbs).replaceAll('\\', '/')
4498
+ const kust = await readFirstYamlObject(kustAbs)
4499
+ if (kust === null) {
4500
+ continue
4501
+ }
4502
+ if (isK8sBaseKustomizationRelPath(rel)) {
4503
+ await verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, passFn, getTreeFlags)
4504
+ } else {
4505
+ await verifyOverlayHpaPdbFileRefsRespectBaseDeployment(
4506
+ rootNorm,
4507
+ kustAbs,
4508
+ rel,
4509
+ kust,
4510
+ fail,
4511
+ passFn,
4512
+ getTreeFlags
4513
+ )
4514
+ }
4515
+ }
4516
+ }
4517
+
4110
4518
  /**
4111
4519
  * Перевіряє прод-оверрайди HPA/PDB в одному kustomization.yaml.
4112
4520
  * @param {Record<string, unknown>} kust об'єкт kustomization
@@ -4376,8 +4784,12 @@ export async function check() {
4376
4784
 
4377
4785
  await validateKustomizationJson6902NoRemoveAddSamePath(root, yamlFiles, fail)
4378
4786
 
4787
+ await validateKustomizationPathRefsExistOnDisk(root, yamlFiles, fail)
4788
+
4379
4789
  await validateKustomizationPatchTargetsResolved(root, yamlFiles, fail)
4380
4790
 
4791
+ await validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFiles, fail, pass)
4792
+
4381
4793
  await ensureBaseKustomizationHasNamespace(root, yamlFiles, fail)
4382
4794
 
4383
4795
  await validateConfigMapNameMatchesDeployment(root, yamlFiles, fail, pass)