@nitra/cursor 1.8.98 → 1.8.100

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/abie.mdc CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Правила для проєктів AbInBev Efes
3
3
  alwaysApply: true
4
- version: '1.14'
4
+ version: '1.15'
5
5
  ---
6
6
 
7
7
  Правило **abie** для споживачів **@nitra/cursor**: **k8s** (Deployment + **HealthCheckPolicy** у **`hc.yaml`**, overlay **ua** / **ru** — **nodeSelector**, **HTTPRoute** (будь-який непорожній **`target.name`**, для спільних сервісів **`auth-run-hl`** / **`filelint-hl`** — **`namespace: dev`** у base та patch **`…/backendRefs/…/namespace`** у **ua** / **ru**), у overlay **ru** — кожен **Service** (у т. ч. **headless** / **`-hl`**) → **`spec.type: NodePort`** через **JSON6902** у **`kustomization.yaml`**, видалення **HealthCheckPolicy** у **ru**), гілки **dev**, **ua**, **ru** у **clean-merged-branch**, а також заборона артефактів **Firebase Hosting** у корені репозиторію.
@@ -115,17 +115,19 @@ spec:
115
115
 
116
116
  ## k8s: overlay **ru** і **Service** (у т. ч. headless → NodePort)
117
117
 
118
- Для кожного **Service** в YAML під **`…/k8s/…`**, де шлях файлу **не** містить **`k8s/ua/`** чи **`k8s/ru/`** (маніфести base / спільного шару; у т. ч. **headless** з **`spec.clusterIP: None`** і **`-hl`**), якщо ще не **`spec.type: NodePort`** / **`LoadBalancer`** / **`ExternalName`**, у **`k8s/ru/kustomization.yaml`** того ж пакета (overlay **ru**) додай **inline** **JSON6902** у **`patches`**: **`target.kind: Service`**, **`target.name`** як у маніфеста, **`path: /spec/type`**, **`value: NodePort`**. Деталі перевірки — **`check-abie.mjs`**.
118
+ Для кожного **Service** в YAML під **`…/k8s/…`**, де шлях файлу **не** містить **`k8s/ua/`** чи **`k8s/ru/`** (маніфести base / спільного шару; у т. ч. **headless** з **`spec.clusterIP: None`** і **`-hl`**), якщо ще не **`spec.type: NodePort`** / **`LoadBalancer`** / **`ExternalName`**, у **`k8s/ru/kustomization.yaml`** того ж пакета (overlay **ru**) додай **inline** **JSON6902** у **`patches`**: **`target.kind: Service`**, **`target.name`** як у маніфеста, **`path: /spec/type`**, **`value: NodePort`**. Якщо в base було **`spec.clusterIP: None`**, у тому ж **patch** додай **`op: remove`** для **`/spec/clusterIP`** (не додавай **`remove`** на **`/spec/clusterIPs`**: у статичному YAML ключа часто немає — **`kubectl kustomize`** падає з *Unable to remove nonexistent key*). Деталі — **`check-abie.mjs`**.
119
119
 
120
- ```yaml title="…/ru/kustomization.yaml (фрагмент)"
120
+ ```yaml title="…/ru/kustomization.yaml (фрагмент, headless → NodePort)"
121
121
  patches:
122
122
  - target:
123
123
  kind: Service
124
- name: my-app
124
+ name: user-site-hl
125
125
  patch: |-
126
126
  - op: replace
127
127
  path: /spec/type
128
128
  value: NodePort
129
+ - op: remove
130
+ path: /spec/clusterIP
129
131
  ```
130
132
 
131
133
  ## k8s: overlay **ru** і HealthCheckPolicy
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.98",
3
+ "version": "1.8.100",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -34,7 +34,7 @@
34
34
  * Вибір **`op`** — **k8s.mdc**.
35
35
  *
36
36
  * **Service (overlay ru):** для кожного **Service**, оголошеного в YAML під **`…/k8s/…`**, де шлях **не** проходить через **`k8s/ua/`** чи **`k8s/ru/`** (маніфести base / спільного шару, у т. ч. **headless** з **`clusterIP: None`** і **`-hl`**), якщо ще не **NodePort** / **LoadBalancer** / **ExternalName**,
37
- * у файлі **`k8s/ru/kustomization.yaml`** того ж пакета (overlay середовища **ru**) — inline **JSON6902** на **`kind: Service`** з тим самим **`target.name`**: **`path: /spec/type`**, **`value: NodePort`**.
37
+ * у файлі **`k8s/ru/kustomization.yaml`** того ж пакета (overlay середовища **ru**) — inline **JSON6902** на **`kind: Service`** з тим самим **`target.name`**: **`path: /spec/type`**, **`value: NodePort`**; якщо в base було **`spec.clusterIP: None`** — у тому ж patch додай **`op: remove`** для **`/spec/clusterIP`** (поле **`clusterIPs`** у статичному YAML часто відсутнє — **`remove`** на **`/spec/clusterIPs`** ламає **`kubectl kustomize`**).
38
38
  */
39
39
  import { existsSync } from 'node:fs'
40
40
  import { readFile } from 'node:fs/promises'
@@ -369,6 +369,51 @@ export function serviceDocumentRequiresAbieRuNodePortOverlay(obj) {
369
369
  return true
370
370
  }
371
371
 
372
+ /**
373
+ * Чи в base-**Service** задано **headless** через **`spec.clusterIP: None`**, який треба прибрати в **ru** перед **NodePort**.
374
+ * @param {unknown} obj корінь YAML (**Service**)
375
+ * @returns {boolean} **true**, якщо **`spec.clusterIP === 'None'`**
376
+ */
377
+ export function serviceDocumentRequiresRuClusterIPNoneRemoval(obj) {
378
+ if (!isServiceDoc(obj)) {
379
+ return false
380
+ }
381
+ const rec = /** @type {Record<string, unknown>} */ (obj)
382
+ const spec = rec.spec
383
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
384
+ return false
385
+ }
386
+ const sp = /** @type {Record<string, unknown>} */ (spec)
387
+ return sp.clusterIP === 'None'
388
+ }
389
+
390
+ /**
391
+ * Чи **JSON6902**-текст містить **`op: remove`** для заданого **`path`** (порядок ключів **op** / **path** неважливий).
392
+ * @param {string} patchText поле **patch** у kustomization
393
+ * @param {string} posixPath наприклад **`/spec/clusterIP`**
394
+ * @returns {boolean} true, якщо знайдено пару **remove** + **path**
395
+ */
396
+ export function jsonPatchRemovesPath(patchText, posixPath) {
397
+ if (typeof patchText !== 'string' || patchText.trim() === '') {
398
+ return false
399
+ }
400
+ if (posixPath !== '/spec/clusterIP') {
401
+ return false
402
+ }
403
+ const pathRe = String.raw`path:\s*\/spec\/clusterIP\b`
404
+ const opRe = String.raw`op:\s*remove\b`
405
+ return new RegExp(`${opRe}[\\s\\S]{0,200}?${pathRe}`, 'mu').test(patchText) || new RegExp(`${pathRe}[\\s\\S]{0,200}?${opRe}`, 'mu').test(patchText)
406
+ }
407
+
408
+ /**
409
+ * Чи patch містить **`op: remove`** для **`/spec/clusterIP`**, щоб прибрати **headless** перед **NodePort**.
410
+ * @param {string} patchText поле **patch** у kustomization
411
+ * @returns {boolean} true, якщо є **remove** для **`/spec/clusterIP`**
412
+ */
413
+ export function jsonPatchTextClearsHeadlessServiceClusterIPNone(patchText) {
414
+ return jsonPatchRemovesPath(patchText, '/spec/clusterIP')
415
+ }
416
+
372
417
  /**
373
418
  * Чи фрагмент **JSON6902** у **`patch`** задає **`/spec/type`** зі значенням **NodePort** (abie overlay **ru**).
374
419
  * @param {string} patchText поле **patch** у kustomization
@@ -430,31 +475,24 @@ function collectAbieServicePatchTextsByNameFromKustomizationDoc(doc) {
430
475
  }
431
476
 
432
477
  /**
433
- * Імена **Service**, для яких у **ru/kustomization.yaml** немає очікуваного patch **`/spec/type` → NodePort** (abie.mdc).
478
+ * Збирає тексти **patch** на **Service** з **kustomization.yaml** (усі документи).
434
479
  * @param {string} raw повний текст **kustomization.yaml**
435
- * @param {Iterable<string>} serviceNames імена **metadata.name** з base-шару
436
- * @returns {string[]} відсортовані імена без коректного patch
480
+ * @returns {Map<string, string>} **target.name** об’єднаний текст **patch**
437
481
  */
438
- export function getMissingAbieRuServiceNodePortPatchServiceNames(raw, serviceNames) {
439
- const req = [...new Set([...serviceNames].filter(n => typeof n === 'string' && n.trim() !== ''))].toSorted((a, b) =>
440
- a.localeCompare(b)
441
- )
442
- if (req.length === 0) {
443
- return []
444
- }
482
+ function collectAbieRuServicePatchTextByTargetNameFromRaw(raw) {
445
483
  const body = stripBom(raw)
446
484
  const lines = body.split(/\r?\n/u)
447
485
  const first = lines[0] ?? ''
448
486
  const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
487
+ /** @type {Map<string, string>} */
488
+ const byName = new Map()
449
489
  /** @type {import('yaml').Document[]} */
450
490
  let docs
451
491
  try {
452
492
  docs = parseAllDocuments(rest)
453
493
  } catch {
454
- return req
494
+ return byName
455
495
  }
456
- /** @type {Map<string, string>} */
457
- const byName = new Map()
458
496
  for (const doc of docs) {
459
497
  const chunk = collectAbieServicePatchTextsByNameFromKustomizationDoc(doc)
460
498
  for (const [k, v] of chunk) {
@@ -462,21 +500,51 @@ export function getMissingAbieRuServiceNodePortPatchServiceNames(raw, serviceNam
462
500
  byName.set(k, prev === undefined ? v : `${prev}\n${v}`)
463
501
  }
464
502
  }
465
- return req.filter(n => {
466
- const pt = byName.get(n)
467
- return pt === undefined || !jsonPatchTextSetsServiceTypeNodePort(pt)
468
- })
503
+ return byName
469
504
  }
470
505
 
471
506
  /**
472
- * Для кожного пакета збирає імена **Service**, які в overlay **ru** мають стати **NodePort** (abie.mdc).
507
+ * Повідомлення про порушення patch **Service** у **ru/kustomization.yaml** (abie.mdc).
508
+ * @param {string} raw повний текст **kustomization.yaml**
509
+ * @param {Map<string, { requiresClusterIPNoneClear: boolean }>} targetsByName ім’я **Service** → чи треба прибрати **None**
510
+ * @returns {string[]} порожньо, якщо все OK
511
+ */
512
+ export function getAbieRuServiceNodePortPatchErrors(raw, targetsByName) {
513
+ if (targetsByName.size === 0) {
514
+ return []
515
+ }
516
+ const byName = collectAbieRuServicePatchTextByTargetNameFromRaw(raw)
517
+ /** @type {string[]} */
518
+ const errors = []
519
+ for (const name of [...targetsByName.keys()].toSorted((a, b) => a.localeCompare(b))) {
520
+ const flags = targetsByName.get(name)
521
+ const requiresClear = flags?.requiresClusterIPNoneClear === true
522
+ const pt = byName.get(name)
523
+ if (pt === undefined || String(pt).trim() === '') {
524
+ errors.push(`${name}: немає inline patch для kind: Service`)
525
+ } else {
526
+ if (!jsonPatchTextSetsServiceTypeNodePort(pt)) {
527
+ errors.push(`${name}: потрібен JSON6902 path /spec/type та value NodePort`)
528
+ }
529
+ if (requiresClear && !jsonPatchTextClearsHeadlessServiceClusterIPNone(pt)) {
530
+ errors.push(
531
+ `${name}: для spec.clusterIP: None додай у той самий patch op: remove для path /spec/clusterIP (abie.mdc)`
532
+ )
533
+ }
534
+ }
535
+ }
536
+ return errors
537
+ }
538
+
539
+ /**
540
+ * Для кожного пакета збирає **Service**, які в overlay **ru** мають стати **NodePort** (abie.mdc).
473
541
  * @param {string} root корінь репозиторію
474
542
  * @param {string[]} yamlAbs абсолютні шляхи yaml під **k8s**
475
543
  * @param {(msg: string) => void} fail реєстрація помилки читання/парсингу
476
- * @returns {Promise<Map<string, Set<string>>>} **pkgAbs** → множина імен **Service**
544
+ * @returns {Promise<Map<string, Map<string, { requiresClusterIPNoneClear: boolean }>>>} **pkgAbs** → (**ім’я** прапорці)
477
545
  */
478
- async function collectAbieRuNodePortServiceNamesByPackage(root, yamlAbs, fail) {
479
- /** @type {Map<string, Set<string>>} */
546
+ async function collectAbieRuNodePortServiceTargetsByPackage(root, yamlAbs, fail) {
547
+ /** @type {Map<string, Map<string, { requiresClusterIPNoneClear: boolean }>>} */
480
548
  const map = new Map()
481
549
  for (const abs of yamlAbs) {
482
550
  const rel = relative(root, abs).replaceAll('\\', '/') || abs
@@ -516,12 +584,16 @@ async function collectAbieRuNodePortServiceNamesByPackage(root, yamlAbs, fail) {
516
584
  const meta = /** @type {Record<string, unknown>} */ (rec.metadata)
517
585
  const n = meta.name
518
586
  if (typeof n === 'string' && n.trim() !== '') {
519
- let s = map.get(pkgAbs)
520
- if (!s) {
521
- s = new Set()
522
- map.set(pkgAbs, s)
587
+ let inner = map.get(pkgAbs)
588
+ if (!inner) {
589
+ inner = new Map()
590
+ map.set(pkgAbs, inner)
523
591
  }
524
- s.add(n)
592
+ const needClear = serviceDocumentRequiresRuClusterIPNoneRemoval(obj)
593
+ const prev = inner.get(n)
594
+ inner.set(n, {
595
+ requiresClusterIPNoneClear: (prev?.requiresClusterIPNoneClear === true) || needClear
596
+ })
525
597
  }
526
598
  }
527
599
  }
@@ -535,7 +607,7 @@ async function collectAbieRuNodePortServiceNamesByPackage(root, yamlAbs, fail) {
535
607
  }
536
608
 
537
609
  /**
538
- * У **`k8s/ru/kustomization.yaml`** для кожного **Service** з YAML **`k8s`**, шлях якого без сегментів **`k8s/ua/`** та **`k8s/ru/`** (у т. ч. **headless** / **`-hl`**) — **JSON6902** **`/spec/type` → NodePort**, якщо ще не **NodePort** / **LoadBalancer** / **ExternalName** (abie.mdc).
610
+ * У **`k8s/ru/kustomization.yaml`** для кожного **Service** з YAML **`k8s`**, шлях якого без сегментів **`k8s/ua/`** та **`k8s/ru/`** (у т. ч. **headless** / **`-hl`**) — **JSON6902** **`/spec/type` → NodePort**; якщо в base було **`clusterIP: None`** — також **`op: remove`** на **`/spec/clusterIP`** (abie.mdc).
539
611
  * @param {string} root корінь
540
612
  * @param {string[]} yamlFilesAbs yaml під **k8s**
541
613
  * @param {(msg: string) => void} fail callback
@@ -543,18 +615,19 @@ async function collectAbieRuNodePortServiceNamesByPackage(root, yamlAbs, fail) {
543
615
  * @returns {Promise<void>}
544
616
  */
545
617
  async function ensureRuAbieServiceNodePortPatches(root, yamlFilesAbs, fail, passFn) {
546
- const byPkg = await collectAbieRuNodePortServiceNamesByPackage(root, yamlFilesAbs, fail)
547
- const entries = [...byPkg.entries()].filter(([, names]) => names.size > 0)
618
+ const byPkg = await collectAbieRuNodePortServiceTargetsByPackage(root, yamlFilesAbs, fail)
619
+ const entries = [...byPkg.entries()].filter(([, m]) => m.size > 0)
548
620
  if (entries.length === 0) {
549
621
  passFn('Немає Service у шарі k8s без k8s/ua/ та k8s/ru/ — patch NodePort у k8s/ru/ не вимагається (abie.mdc)')
550
622
  return
551
623
  }
552
- for (const [pkgAbs, names] of entries.toSorted((a, b) => a[0].localeCompare(b[0]))) {
624
+ for (const [pkgAbs, targetsByName] of entries.toSorted((a, b) => a[0].localeCompare(b[0]))) {
553
625
  const relPkg = relative(root, pkgAbs).replaceAll('\\', '/') || pkgAbs
554
626
  const ruAbs = join(pkgAbs, 'k8s', 'ru', 'kustomization.yaml')
627
+ const nameList = [...targetsByName.keys()].toSorted((a, b) => a.localeCompare(b))
555
628
  if (!existsSync(ruAbs)) {
556
629
  fail(
557
- `${relPkg}/k8s: є Service (у т. ч. headless), для overlay ru потрібен patch /spec/type → NodePort: ${[...names].toSorted((a, b) => a.localeCompare(b)).join(', ')} — додай ru/kustomization.yaml (abie.mdc)`
630
+ `${relPkg}/k8s: є Service, для overlay ru потрібен patch Service (NodePort; для headless — ще remove /spec/clusterIP): ${nameList.join(', ')} — додай ru/kustomization.yaml (abie.mdc)`
558
631
  )
559
632
  return
560
633
  }
@@ -567,11 +640,9 @@ async function ensureRuAbieServiceNodePortPatches(root, yamlFilesAbs, fail, pass
567
640
  fail(`${relRu}: не вдалося прочитати (${msg})`)
568
641
  return
569
642
  }
570
- const missing = getMissingAbieRuServiceNodePortPatchServiceNames(raw, names)
571
- if (missing.length > 0) {
572
- fail(
573
- `${relRu}: для kind: Service потрібен inline JSON6902 з path /spec/type та value NodePort (ім’я target: ${missing.join(', ')}) — abie.mdc`
574
- )
643
+ const patchErrors = getAbieRuServiceNodePortPatchErrors(raw, targetsByName)
644
+ if (patchErrors.length > 0) {
645
+ fail(`${relRu}: ${patchErrors.join('; ')}`)
575
646
  return
576
647
  }
577
648
  passFn(`${relRu}: patch Service → NodePort (ru) відповідає abie.mdc`)