@nitra/cursor 1.8.127 → 1.8.128

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
@@ -298,6 +298,8 @@ data:
298
298
 
299
299
  **Локальні шляхи в `kustomization.yaml`:** кожен запис без `://` (remote) з `resources` / `bases` / `components` / `crds`, `patchesStrategicMerge`, `patches[].path`, `patchesJson6902[].path`, `configurations[]`, `replacements[].path` має вказувати на **існуючий** у репозиторії файл (`.yaml` / `.yml`) або **каталог**; биті посилання — помилка **`check k8s`**.
300
300
 
301
+ **Порядок `resources`:** у маніфесті з **`apiVersion: kustomize.config.k8s.io/…`**, **`kind: Kustomization`**, елементи **`resources:`** (лише непорожні рядки) мають бути **відсортовані за алфавітом (англ. локаль, як `localeCompare('en')` у `check-k8s.mjs`)**. Поля **`bases`**, **`components`**, **`crds`** цією перевіркою **не** впорядковуються.
302
+
301
303
  ### Env-залежні межі (за сегментом після `/k8s/`)
302
304
 
303
305
  **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.127",
3
+ "version": "1.8.128",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -225,6 +225,20 @@ export function isAllowedAbieBaseDevHostname(hostname) {
225
225
  return false
226
226
  }
227
227
 
228
+ /**
229
+ * @param {unknown} hostnames значення поля spec.hostnames
230
+ * @returns {string[]} непорожні рядки-хости
231
+ */
232
+ function collectAbieHostnames(hostnames) {
233
+ if (Array.isArray(hostnames)) {
234
+ return hostnames.filter(h => typeof h === 'string' && h.trim() !== '')
235
+ }
236
+ if (typeof hostnames === 'string' && hostnames.trim() !== '') {
237
+ return [hostnames]
238
+ }
239
+ return []
240
+ }
241
+
228
242
  /**
229
243
  * Повідомлення про недопустимі **spec.hostnames** у **HTTPRoute** у шляху **…/base/…** (abie.mdc).
230
244
  * @param {unknown} obj корінь YAML-документа
@@ -232,49 +246,23 @@ export function isAllowedAbieBaseDevHostname(hostname) {
232
246
  * @returns {string[]} порожньо, якщо перевірка не застосовується або hostnames коректні
233
247
  */
234
248
  export function abieBaseHttpRouteHostnamesErrors(obj, rel) {
235
- if (!isAbieK8sBaseYamlPath(rel)) {
236
- return []
237
- }
238
- if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
239
- return []
240
- }
249
+ if (!isAbieK8sBaseYamlPath(rel)) return []
250
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return []
241
251
  const rec = /** @type {Record<string, unknown>} */ (obj)
242
- if (rec.kind !== 'HTTPRoute') {
243
- return []
244
- }
252
+ if (rec.kind !== 'HTTPRoute') return []
245
253
  const spec = rec.spec
246
- if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
247
- return []
248
- }
254
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return []
249
255
  const hostnames = /** @type {Record<string, unknown>} */ (spec).hostnames
250
- if (hostnames === undefined) {
251
- return []
252
- }
253
- /** @type {string[]} */
254
- const hosts = []
255
- if (Array.isArray(hostnames)) {
256
- for (const h of hostnames) {
257
- if (typeof h === 'string' && h.trim() !== '') {
258
- hosts.push(h)
259
- }
260
- }
261
- } else if (typeof hostnames === 'string' && hostnames.trim() !== '') {
262
- hosts.push(hostnames)
263
- }
264
- if (hosts.length === 0) {
265
- return []
266
- }
256
+ if (hostnames === undefined) return []
257
+ const hosts = collectAbieHostnames(hostnames)
258
+ if (hosts.length === 0) return []
267
259
  const root = ABIE_BASE_DEV_HTTPROUTE_HOST_ROOT
268
- /** @type {string[]} */
269
- const errors = []
270
- for (const h of hosts) {
271
- if (!isAllowedAbieBaseDevHostname(h)) {
272
- errors.push(
260
+ return hosts
261
+ .filter(h => !isAllowedAbieBaseDevHostname(h))
262
+ .map(
263
+ h =>
273
264
  `${rel}: HTTPRoute у base (dev): hostname "${h}" недопустимий — дозволені лише ${root} та піддомени, зокрема *.${root} (abie.mdc)`
274
- )
275
- }
276
- }
277
- return errors
265
+ )
278
266
  }
279
267
 
280
268
  /**
@@ -1591,6 +1579,37 @@ async function checkHttpRouteKustomization(abs, rel, mode, root, yamlFilesAbs, c
1591
1579
  return true
1592
1580
  }
1593
1581
 
1582
+ /**
1583
+ * @param {unknown} json YAML-документ
1584
+ * @returns {boolean} true, якщо HTTPRoute має непорожні spec.hostnames
1585
+ */
1586
+ function httpRouteHasNonEmptyHostnames(json) {
1587
+ if (json === null || typeof json !== 'object' || Array.isArray(json)) return false
1588
+ const rec = /** @type {Record<string, unknown>} */ (json)
1589
+ if (rec.kind !== 'HTTPRoute') return false
1590
+ const spec = rec.spec
1591
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return false
1592
+ const hostnames = /** @type {Record<string, unknown>} */ (spec).hostnames
1593
+ return collectAbieHostnames(hostnames).length > 0
1594
+ }
1595
+
1596
+ /**
1597
+ * @param {import('yaml').Document} doc YAML-документ з файлу
1598
+ * @param {string} rel відносний шлях для повідомлень
1599
+ * @param {(msg: string) => void} fail callback при помилці
1600
+ * @returns {{ hasErrors: boolean, hasHostnames: boolean }} результат обробки документа
1601
+ */
1602
+ function processBaseHttpRouteDoc(doc, rel, fail) {
1603
+ if (doc.errors.length !== 0) return { hasErrors: false, hasHostnames: false }
1604
+ const json = doc.toJSON()
1605
+ const errs = abieBaseHttpRouteHostnamesErrors(json, rel)
1606
+ if (errs.length > 0) {
1607
+ for (const e of errs) fail(e)
1608
+ return { hasErrors: true, hasHostnames: false }
1609
+ }
1610
+ return { hasErrors: false, hasHostnames: httpRouteHasNonEmptyHostnames(json) }
1611
+ }
1612
+
1594
1613
  /**
1595
1614
  * Для кожного **HTTPRoute** у **`…/k8s/base/…`** з непорожніми **`spec.hostnames`** — лише **aiml.live** та піддомени (abie.mdc).
1596
1615
  * @param {string} root корінь репозиторію
@@ -1601,39 +1620,15 @@ async function checkHttpRouteKustomization(abs, rel, mode, root, yamlFilesAbs, c
1601
1620
  */
1602
1621
  async function ensureAbieBaseHttpRouteHostnames(root, yamlFilesAbs, fail, passFn) {
1603
1622
  let baseHttpRoutesWithHostnames = 0
1604
- for (const abs of yamlFilesAbs) {
1623
+ const baseFiles = yamlFilesAbs.filter(abs => isAbieK8sBaseYamlPath(relative(root, abs).replaceAll('\\', '/') || abs))
1624
+ for (const abs of baseFiles) {
1605
1625
  const rel = relative(root, abs).replaceAll('\\', '/') || abs
1606
- if (isAbieK8sBaseYamlPath(rel)) {
1607
- const docs = await readAndParseYamlDocs(abs, rel, fail)
1608
- if (!docs) {
1609
- return
1610
- }
1611
- for (const doc of docs) {
1612
- if (doc.errors.length === 0) {
1613
- const json = doc.toJSON()
1614
- const errs = abieBaseHttpRouteHostnamesErrors(json, rel)
1615
- if (errs.length > 0) {
1616
- for (const e of errs) {
1617
- fail(e)
1618
- }
1619
- return
1620
- }
1621
- if (json !== null && typeof json === 'object' && !Array.isArray(json)) {
1622
- const rec = /** @type {Record<string, unknown>} */ (json)
1623
- if (rec.kind === 'HTTPRoute') {
1624
- const spec = rec.spec
1625
- if (spec !== null && typeof spec === 'object' && !Array.isArray(spec)) {
1626
- const hostnames = /** @type {Record<string, unknown>} */ (spec).hostnames
1627
- if (Array.isArray(hostnames) && hostnames.some(h => typeof h === 'string' && h.trim() !== '')) {
1628
- baseHttpRoutesWithHostnames++
1629
- } else if (typeof hostnames === 'string' && hostnames.trim() !== '') {
1630
- baseHttpRoutesWithHostnames++
1631
- }
1632
- }
1633
- }
1634
- }
1635
- }
1636
- }
1626
+ const docs = await readAndParseYamlDocs(abs, rel, fail)
1627
+ if (!docs) return
1628
+ for (const doc of docs) {
1629
+ const { hasErrors, hasHostnames } = processBaseHttpRouteDoc(doc, rel, fail)
1630
+ if (hasErrors) return
1631
+ if (hasHostnames) baseHttpRoutesWithHostnames++
1637
1632
  }
1638
1633
  }
1639
1634
  if (baseHttpRoutesWithHostnames > 0) {
@@ -1685,7 +1680,6 @@ async function ensureUaRuAbieHttpRoutePatches(root, yamlFilesAbs, fail, passFn)
1685
1680
  /**
1686
1681
  * Перевіряє відсутність артефактів Firebase Hosting у **кожному** **підкаталозі першого рівня** від кореня
1687
1682
  * (не в самому корені репозиторію) — abie.mdc. Каталоги **`.git`** і **`node_modules`** у скануванні пропускаються.
1688
- *
1689
1683
  * @param {string} root корінь репозиторію
1690
1684
  * @param {(msg: string) => void} passFn успішне повідомлення
1691
1685
  * @param {(msg: string) => void} failFn повідомлення про порушення
@@ -1700,9 +1694,7 @@ async function ensureNoFirebaseHostingArtifacts(root, passFn, failFn) {
1700
1694
  failFn(`Не вдалося прочитати ${root} для перевірки Firebase Hosting: ${msg} (abie.mdc)`)
1701
1695
  return
1702
1696
  }
1703
- const topDirs = entries.filter(
1704
- e => e.isDirectory() && !ABIE_FIREBASE_HOSTING_SCAN_SKIP_TOP_DIR_NAMES.has(e.name)
1705
- )
1697
+ const topDirs = entries.filter(e => e.isDirectory() && !ABIE_FIREBASE_HOSTING_SCAN_SKIP_TOP_DIR_NAMES.has(e.name))
1706
1698
  let hasViolation = false
1707
1699
  for (const e of topDirs) {
1708
1700
  for (const name of ['.firebaserc', 'firebase.json']) {
@@ -1720,9 +1712,7 @@ async function ensureNoFirebaseHostingArtifacts(root, passFn, failFn) {
1720
1712
  if (hasViolation) {
1721
1713
  return
1722
1714
  }
1723
- passFn(
1724
- 'Підкаталоги кореня (1-й рівень, без .git/node_modules): артефактів Firebase Hosting не знайдено (abie.mdc)'
1725
- )
1715
+ passFn('Підкаталоги кореня (1-й рівень, без .git/node_modules): артефактів Firebase Hosting не знайдено (abie.mdc)')
1726
1716
  }
1727
1717
 
1728
1718
  /**
@@ -24,6 +24,8 @@ import { lintDockerfileWithHadolint, posixRel } from './utils/docker-hadolint.mj
24
24
  import { createCheckReporter } from './utils/check-reporter.mjs'
25
25
  import { walkDir } from './utils/walkDir.mjs'
26
26
 
27
+ const NEWLINE_RE = /\r?\n/
28
+
27
29
  /**
28
30
  * @typedef {{
29
31
  * line: number
@@ -59,30 +61,25 @@ export async function findDockerfilePaths(root) {
59
61
 
60
62
  /**
61
63
  * Витягує всі `FROM <image>` зі вмісту Dockerfile/Containerfile.
62
- *
63
64
  * @param {string} fileContent вміст Dockerfile/Containerfile
64
65
  * @returns {FromStage[]} список знайдених FROM-інструкцій
65
66
  */
66
67
  export function parseFromStages(fileContent) {
67
68
  const out = []
68
- const lines = fileContent.split(/\r?\n/)
69
- for (let i = 0; i < lines.length; i++) {
70
- const image = getFromImageToken(lines[i])
69
+ const lines = fileContent.split(NEWLINE_RE)
70
+ for (const [i, line] of lines.entries()) {
71
+ const image = getFromImageToken(line)
71
72
  if (image) out.push({ line: i + 1, image })
72
73
  }
73
74
  return out
74
75
  }
75
76
 
76
- const RUNTIME_IMAGES = /** @type {const} */ ([
77
- 'mirror.gcr.io/library/alpine',
78
- 'mirror.gcr.io/library/nginx'
79
- ])
77
+ const RUNTIME_IMAGES = /** @type {const} */ (['mirror.gcr.io/library/alpine', 'mirror.gcr.io/library/nginx'])
80
78
 
81
79
  /**
82
80
  * Перевіряє базові вимоги до структури Dockerfile:
83
81
  * - multistage: мінімум 2 FROM
84
82
  * - фінальний FROM: alpine або nginx з mirror.gcr.io
85
- *
86
83
  * @param {string} fileContent вміст Dockerfile/Containerfile
87
84
  * @returns {string | null} повідомлення помилки або null
88
85
  */
@@ -136,12 +136,12 @@ function verifyNoRunShellLineContinuationBackslash(relPath, content, failFn, pas
136
136
  }
137
137
  const hits = findRunStepsWithShellLineContinuationBackslash(root)
138
138
  if (hits.length === 0) {
139
- passFn(`${relPath}: run без shell-продовження через \\ (ga.mdc)`)
139
+ passFn(String.raw`${relPath}: run без shell-продовження через \ (ga.mdc)`)
140
140
  return
141
141
  }
142
142
  for (const h of hits) {
143
143
  failFn(
144
- `${relPath}: job ${h.jobId}, крок ${h.stepIndex + 1}: у run заборонено продовження рядків через зворотний сліш; довгі команди оформи як folded block (run: >-) без \\ на кінцях рядків (ga.mdc)`
144
+ String.raw`${relPath}: job ${h.jobId}, крок ${h.stepIndex + 1}: у run заборонено продовження рядків через зворотний сліш; довгі команди оформи як folded block (run: >-) без \ на кінцях рядків (ga.mdc)`
145
145
  )
146
146
  }
147
147
  }
@@ -237,9 +237,7 @@ async function checkVscodeSettingsForGa(passFn, failFn) {
237
237
  }
238
238
  const block = /** @type {Record<string, unknown>} */ (settings)['[github-actions-workflow]']
239
239
  if (!block || typeof block !== 'object' || block === null || Array.isArray(block)) {
240
- failFn(
241
- `${rel}: додай "[github-actions-workflow]": { "editor.defaultFormatter": "oxc.oxc-vscode" } (ga.mdc)`
242
- )
240
+ failFn(`${rel}: додай "[github-actions-workflow]": { "editor.defaultFormatter": "oxc.oxc-vscode" } (ga.mdc)`)
243
241
  return
244
242
  }
245
243
  const df = String(/** @type {Record<string, unknown>} */ (block)['editor.defaultFormatter'] ?? '')
@@ -42,7 +42,8 @@
42
42
  * в **тому ж каталозі**, що й **`svc.yaml`** (логіка збігається з **`pathsFromKustomizationObject`**).
43
43
  *
44
44
  * Структура **Kustomize** (див. k8s.mdc): заборона шляхів **`…/k8s/dev/…`**; у **`k8s/base/kustomization.yaml`**
45
- * завжди має бути непорожнє поле **`namespace:`** (перевірка, якщо файл існує).
45
+ * завжди має бути непорожнє поле **`namespace:`** (перевірка, якщо файл існує). У **`apiVersion: kustomize.config.k8s.io/…`**, **`kind: Kustomization`**
46
+ * перелік **`resources:`** (лише непорожні рядки) має бути відсортовано за алфавітом (**en**, `localeCompare`).
46
47
  *
47
48
  * **Inline JSON6902** у **`patches`** (і зовнішні файли з **`patches[].path`** під **`k8s`**, якщо вміст — масив JSON Patch): не допускається пара **`remove`** і **`add`**
48
49
  * на один і той самий **`path`** у межах одного фрагмента — потрібен **`op: replace`** (k8s.mdc). **check-k8s** це перевіряє.
@@ -365,6 +366,77 @@ function pushStringPaths(arr, acc) {
365
366
  }
366
367
  }
367
368
 
369
+ /** Префікс `apiVersion` для маніфесту Kustomize **Kustomization**. */
370
+ const KUSTOMIZE_CONFIG_API_PREFIX = 'kustomize.config.k8s.io/'
371
+
372
+ /**
373
+ * Чи послідовність непорожніх рядків зростаюча за `localeCompare` (en).
374
+ * @param {string[]} paths
375
+ * @returns {boolean}
376
+ */
377
+ function stringPathsAreSortedEn(paths) {
378
+ for (let i = 1; i < paths.length; i++) {
379
+ if (paths[i - 1].localeCompare(paths[i], 'en', { sensitivity: 'base' }) > 0) {
380
+ return false
381
+ }
382
+ }
383
+ return true
384
+ }
385
+
386
+ /**
387
+ * Порушення сорту **`resources`**: лише для **`kustomize.config.k8s.io/…`**, **`kind: Kustomization`**.
388
+ * Порожні рядки в списку ігноруються (як у `pushStringPaths`).
389
+ * @param {unknown} obj корінь першого YAML-документа
390
+ * @returns {string | null} причина або `null`, якщо обмеження не застосовується
391
+ */
392
+ export function kustomizationResourcesSortedAlphabeticallyViolation(obj) {
393
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return null
394
+ const rec = /** @type {Record<string, unknown>} */ (obj)
395
+ if (rec.kind !== 'Kustomization') return null
396
+ const av = rec.apiVersion
397
+ if (typeof av !== 'string' || !av.startsWith(KUSTOMIZE_CONFIG_API_PREFIX)) return null
398
+ const res = rec.resources
399
+ if (res === undefined) return null
400
+ if (!Array.isArray(res)) {
401
+ return 'Kustomization.resources має бути масивом (k8s.mdc)'
402
+ }
403
+ /** @type {string[]} */
404
+ const paths = []
405
+ for (let i = 0; i < res.length; i++) {
406
+ const item = res[i]
407
+ if (typeof item !== 'string') {
408
+ return `Kustomization.resources[${i}] — очікується рядок-шлях (k8s.mdc)`
409
+ }
410
+ const t = item.trim()
411
+ if (t !== '') paths.push(t)
412
+ }
413
+ if (paths.length < 2) return null
414
+ if (!stringPathsAreSortedEn(paths)) {
415
+ const want = [...paths].sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }))
416
+ return `Kustomization.resources має бути за алфавітом (en). Зараз: ${paths.join(', ')}; очікувано: ${want.join(', ')} (k8s.mdc)`
417
+ }
418
+ return null
419
+ }
420
+
421
+ /**
422
+ * Усі **`kustomization.yaml`**: **`resources`**, відсортовані за en.
423
+ * @param {string} root корінь репо
424
+ * @param {string[]} yamlFilesAbs yaml під k8s
425
+ * @param {(msg: string) => void} fail
426
+ * @returns {Promise<void>}
427
+ */
428
+ async function validateKustomizationResourcesSortedAlphabetically(root, yamlFilesAbs, fail) {
429
+ for (const kustAbs of yamlFilesAbs.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')) {
430
+ const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
431
+ const kust = await readFirstYamlObject(kustAbs)
432
+ if (kust === null) continue
433
+ const v = kustomizationResourcesSortedAlphabeticallyViolation(kust)
434
+ if (v !== null) {
435
+ fail(`${rel}: ${v}`)
436
+ }
437
+ }
438
+ }
439
+
368
440
  /**
369
441
  * Шляхи з полів Kustomization для resolve відносно каталогу **`kustomization.yaml`**.
370
442
  * @param {unknown} obj корінь першого документа Kustomization
@@ -397,6 +469,35 @@ function pathsFromKustomizationObject(obj) {
397
469
  return out
398
470
  }
399
471
 
472
+ /**
473
+ * @param {unknown} arr масив (може бути не масивом)
474
+ * @param {string[]} out вихідний масив
475
+ */
476
+ function collectObjectPathFields(arr, out) {
477
+ if (!Array.isArray(arr)) return
478
+ for (const item of arr) {
479
+ if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
480
+ const pth = /** @type {Record<string, unknown>} */ (item).path
481
+ if (typeof pth === 'string' && pth.trim() !== '') {
482
+ out.push(pth.trim())
483
+ }
484
+ }
485
+ }
486
+ }
487
+
488
+ /**
489
+ * @param {unknown} arr масив (може бути не масивом)
490
+ * @param {string[]} out вихідний масив
491
+ */
492
+ function collectStringPaths(arr, out) {
493
+ if (!Array.isArray(arr)) return
494
+ for (const c of arr) {
495
+ if (typeof c === 'string' && c.trim() !== '') {
496
+ out.push(c.trim())
497
+ }
498
+ }
499
+ }
500
+
400
501
  /**
401
502
  * Унікальні локальні шляхи з `kustomization.yaml` для перевірки існування на диску:
402
503
  * як у `pathsFromKustomizationObject`, плюс **`patchesJson6902[].path`**, плюс **`configurations[]`**
@@ -410,37 +511,48 @@ export function kustomizePathRefsForExistenceCheck(obj) {
410
511
  }
411
512
  const fromPaths = pathsFromKustomizationObject(obj)
412
513
  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
- }
514
+ collectObjectPathFields(rec.patchesJson6902, fromPaths)
515
+ collectStringPaths(rec.configurations, fromPaths)
516
+ collectObjectPathFields(rec.replacements, fromPaths)
517
+ return [...new Set(fromPaths)]
518
+ }
519
+
520
+ /**
521
+ * @param {string} rel відносний шлях файлу
522
+ * @param {string} r посилання з kustomization
523
+ * @param {string} kustDir каталог kustomization.yaml
524
+ * @param {string} rootNorm нормалізований корінь
525
+ * @param {(msg: string) => void} fail callback
526
+ * @returns {Promise<void>}
527
+ */
528
+ async function validateKustomizationRef(rel, r, kustDir, rootNorm, fail) {
529
+ const target = resolve(kustDir, r.trim())
530
+ if (!resolvedFilePathIsUnderRoot(rootNorm, target)) {
531
+ fail(
532
+ `${rel}: посилання «${r}» виходить за межі репозиторію (resolve: ${(
533
+ relative(rootNorm, target) || target
534
+ ).replaceAll('\\', '/')}) (k8s.mdc)`
535
+ )
536
+ return
423
537
  }
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
- }
538
+ /** @type {import('node:fs').Stats | undefined} */
539
+ let st
540
+ try {
541
+ st = await stat(target)
542
+ } catch {
543
+ st = undefined
431
544
  }
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
- }
545
+ if (st === undefined) {
546
+ fail(`${rel}: посилання «${r}» вказує на неіснуючий ресурс (очікувано файл або каталог; k8s.mdc)`)
547
+ } else if (st.isFile()) {
548
+ if (!YAML_EXTENSION_RE.test(target)) {
549
+ fail(
550
+ `${rel}: «${r}» за правилами k8s у kustomization для файлів дозволені лише розширення .yaml / .yml (k8s.mdc)`
551
+ )
441
552
  }
553
+ } else if (!st.isDirectory()) {
554
+ fail(`${rel}: «${r}» — ні файл, ні каталог (k8s.mdc)`)
442
555
  }
443
- return [...new Set(fromPaths)]
444
556
  }
445
557
 
446
558
  /**
@@ -454,44 +566,14 @@ export function kustomizePathRefsForExistenceCheck(obj) {
454
566
  async function validateOneKustomizationPathRefsExist(root, kustAbs, rootNorm, fail) {
455
567
  const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
456
568
  const kust = await readFirstYamlObject(kustAbs)
457
- if (kust === null) {
458
- return
459
- }
460
- if (kust.kind !== 'Kustomization') {
569
+ if (kust === null || kust.kind !== 'Kustomization') {
461
570
  return
462
571
  }
463
572
  const refs = kustomizePathRefsForExistenceCheck(kust)
464
573
  const kustDir = dirname(resolve(kustAbs))
465
574
  for (const r of refs) {
466
575
  if (typeof r === 'string' && !r.includes('://') && r.trim() !== '') {
467
- const target = resolve(kustDir, r.trim())
468
- if (resolvedFilePathIsUnderRoot(rootNorm, target)) {
469
- /** @type {import('node:fs').Stats | undefined} */
470
- let st
471
- try {
472
- st = await stat(target)
473
- } catch {
474
- st = undefined
475
- }
476
- if (st === undefined) {
477
- fail(
478
- `${rel}: посилання «${r}» вказує на неіснуючий ресурс (очікувано файл або каталог; k8s.mdc)`
479
- )
480
- } else if (st.isFile()) {
481
- if (!YAML_EXTENSION_RE.test(target)) {
482
- fail(
483
- `${rel}: «${r}» — за правилами k8s у kustomization для файлів дозволені лише розширення .yaml / .yml (k8s.mdc)`
484
- )
485
- }
486
- } else if (!st.isDirectory()) {
487
- fail(`${rel}: «${r}» — ні файл, ні каталог (k8s.mdc)`)
488
- }
489
- } else {
490
- fail(
491
- `${rel}: посилання «${r}» виходить за межі репозиторію (resolve: ${(relative(rootNorm, target) || target).replaceAll('\\', '/')
492
- }) (k8s.mdc)`
493
- )
494
- }
576
+ await validateKustomizationRef(rel, r, kustDir, rootNorm, fail)
495
577
  }
496
578
  }
497
579
  }
@@ -4279,6 +4361,24 @@ function isResolvedFileUnderDirectory(dirAbs, fileAbs) {
4279
4361
  return !r.startsWith('../') && r !== '..'
4280
4362
  }
4281
4363
 
4364
+ /**
4365
+ * @param {string} resolved абсолютний шлях
4366
+ * @param {string} rootNorm нормалізований корінь
4367
+ * @returns {Promise<boolean>} true, якщо resolved є k8s base-каталогом з kustomization.yaml
4368
+ */
4369
+ async function isK8sBaseDir(resolved, rootNorm) {
4370
+ if (basename(resolved) !== 'base') return false
4371
+ if (!existsSync(join(resolved, 'kustomization.yaml'))) return false
4372
+ if (!isUnderK8sPathRelToRoot(rootNorm, resolved)) return false
4373
+ let st
4374
+ try {
4375
+ st = await stat(resolved)
4376
+ } catch {
4377
+ return false
4378
+ }
4379
+ return st.isDirectory()
4380
+ }
4381
+
4282
4382
  /**
4283
4383
  * За списку посилань kustomize повертає каталоги `.../base` з `kustomization.yaml` (наслідування base).
4284
4384
  * @param {string} kustDir каталог kustomization.yaml
@@ -4292,22 +4392,8 @@ async function k8sBaseDirsFromKustomizeResourcePathRefs(kustDir, pathRefs, rootN
4292
4392
  for (const ref of pathRefs) {
4293
4393
  if (typeof ref === 'string' && !ref.includes('://') && ref.trim() !== '') {
4294
4394
  const resolved = resolve(kustDir, ref.trim())
4295
- if (resolvedFilePathIsUnderRoot(rootNorm, resolved)) {
4296
- let st
4297
- try {
4298
- st = await stat(resolved)
4299
- } catch {
4300
- st = undefined
4301
- }
4302
- if (
4303
- st !== undefined
4304
- && st.isDirectory()
4305
- && basename(resolved) === 'base'
4306
- && existsSync(join(resolved, 'kustomization.yaml'))
4307
- && isUnderK8sPathRelToRoot(rootNorm, resolved)
4308
- ) {
4309
- out.push(resolved)
4310
- }
4395
+ if (resolvedFilePathIsUnderRoot(rootNorm, resolved) && (await isK8sBaseDir(resolved, rootNorm))) {
4396
+ out.push(resolved)
4311
4397
  }
4312
4398
  }
4313
4399
  }
@@ -4368,9 +4454,7 @@ async function verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, pa
4368
4454
  const { hasDeployment, hasHpa, hasPdb } = await getTreeFlags(kustAbs)
4369
4455
  if (hasHpa || hasPdb) {
4370
4456
  if (hasDeployment) {
4371
- passFn(
4372
- `${rel}: у дереві kustomize base є HPA/PDB і Deployment (k8s.mdc)`
4373
- )
4457
+ passFn(`${rel}: у дереві kustomize base є HPA/PDB і Deployment (k8s.mdc)`)
4374
4458
  } else {
4375
4459
  fail(
4376
4460
  `${rel}: у base є HorizontalPodAutoscaler і/або PodDisruptionBudget у resources/bases/…, але дерева kustomize не містить Deployment — HPA і PDB дозволені тільки разом із Deployment (k8s.mdc)`
@@ -4407,47 +4491,52 @@ async function verifyOverlayHpaPdbFileRefsRespectBaseDeployment(
4407
4491
  return
4408
4492
  }
4409
4493
 
4410
- const anyBaseHasDep = await (async () => {
4411
- for (const baseDir of baseDirs) {
4412
- const { hasDeployment: h } = await getTreeFlags(join(baseDir, 'kustomization.yaml'))
4413
- if (h) {
4414
- return true
4415
- }
4416
- }
4417
- return false
4418
- })()
4494
+ const treeFlags = await Promise.all(baseDirs.map(bd => getTreeFlags(join(bd, 'kustomization.yaml'))))
4495
+ const anyBaseHasDep = treeFlags.some(f => f.hasDeployment)
4496
+
4419
4497
  for (const ref of pathRefs) {
4420
4498
  if (typeof ref === 'string' && !ref.includes('://') && ref.trim() !== '') {
4421
- const fAbs = resolve(kustDir, ref.trim())
4422
- if (resolvedFilePathIsUnderRoot(root, fAbs) && existsSync(fAbs)) {
4423
- let st
4424
- try {
4425
- st = await stat(fAbs)
4426
- } catch {
4427
- st = undefined
4428
- }
4429
- if (st !== undefined && st.isFile() && YAML_EXTENSION_RE.test(fAbs)) {
4430
- const fUnderSomeBase = baseDirs.some(bd => isResolvedFileUnderDirectory(bd, fAbs))
4431
- if (!fUnderSomeBase) {
4432
- const hpaPdb = await yamlFileContainsHpaOrPdbDocument(fAbs)
4433
- if (hpaPdb) {
4434
- if (anyBaseHasDep) {
4435
- passFn(
4436
- `${rel}: overlay-файл «${(relative(root, fAbs) || ref).replaceAll('\\', '/')}» з HPA/PDB, base містить Deployment (k8s.mdc)`
4437
- )
4438
- } else {
4439
- fail(
4440
- `${rel}: посилання «${ref}» містить HorizontalPodAutoscaler і/або PodDisruptionBudget, а наслідуваний k8s/base не дає у дереві Deployment — прибери HPA/PDB або додай Deployment у base (k8s.mdc)`
4441
- )
4442
- }
4443
- }
4444
- }
4445
- }
4446
- }
4499
+ await checkOverlayRefHpaPdb(root, kustDir, rel, ref, baseDirs, anyBaseHasDep, fail, passFn)
4447
4500
  }
4448
4501
  }
4449
4502
  }
4450
4503
 
4504
+ /**
4505
+ * @param {string} root нормалізований корінь
4506
+ * @param {string} kustDir каталог kustomization.yaml
4507
+ * @param {string} rel відносний шлях для повідомлень
4508
+ * @param {string} ref посилання з pathRefs
4509
+ * @param {string[]} baseDirs масив base-каталогів
4510
+ * @param {boolean} anyBaseHasDep чи є Deployment у base
4511
+ * @param {(msg: string) => void} fail callback
4512
+ * @param {(msg: string) => void} passFn callback
4513
+ * @returns {Promise<void>}
4514
+ */
4515
+ async function checkOverlayRefHpaPdb(root, kustDir, rel, ref, baseDirs, anyBaseHasDep, fail, passFn) {
4516
+ const fAbs = resolve(kustDir, ref.trim())
4517
+ if (!resolvedFilePathIsUnderRoot(root, fAbs) || !existsSync(fAbs)) return
4518
+ let st
4519
+ try {
4520
+ st = await stat(fAbs)
4521
+ } catch {
4522
+ return
4523
+ }
4524
+ if (!st.isFile() || !YAML_EXTENSION_RE.test(fAbs)) return
4525
+ const fUnderSomeBase = baseDirs.some(bd => isResolvedFileUnderDirectory(bd, fAbs))
4526
+ if (fUnderSomeBase) return
4527
+ const hpaPdb = await yamlFileContainsHpaOrPdbDocument(fAbs)
4528
+ if (!hpaPdb) return
4529
+ if (anyBaseHasDep) {
4530
+ passFn(
4531
+ `${rel}: overlay-файл «${(relative(root, fAbs) || ref).replaceAll('\\', '/')}» з HPA/PDB, base містить Deployment (k8s.mdc)`
4532
+ )
4533
+ } else {
4534
+ fail(
4535
+ `${rel}: посилання «${ref}» містить HorizontalPodAutoscaler і/або PodDisruptionBudget, а наслідуваний k8s/base не дає у дереві Deployment — прибери HPA/PDB або додай Deployment у base (k8s.mdc)`
4536
+ )
4537
+ }
4538
+ }
4539
+
4451
4540
  /**
4452
4541
  * Перевіряє всі кастомізації: (1) у k8s/base дереві HPA/PDB тільки з Deployment; (2) overlay, що
4453
4542
  * посилається на base, не додає HPA/PDB без Deployment у base.
@@ -4482,15 +4571,7 @@ async function validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFilesAbs,
4482
4571
  if (isK8sBaseKustomizationRelPath(rel)) {
4483
4572
  await verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, passFn, getTreeFlags)
4484
4573
  } else {
4485
- await verifyOverlayHpaPdbFileRefsRespectBaseDeployment(
4486
- rootNorm,
4487
- kustAbs,
4488
- rel,
4489
- kust,
4490
- fail,
4491
- passFn,
4492
- getTreeFlags
4493
- )
4574
+ await verifyOverlayHpaPdbFileRefsRespectBaseDeployment(rootNorm, kustAbs, rel, kust, fail, passFn, getTreeFlags)
4494
4575
  }
4495
4576
  }
4496
4577
  }
@@ -4767,6 +4848,8 @@ export async function check() {
4767
4848
 
4768
4849
  await validateKustomizationPathRefsExistOnDisk(root, yamlFiles, fail)
4769
4850
 
4851
+ await validateKustomizationResourcesSortedAlphabetically(root, yamlFiles, fail)
4852
+
4770
4853
  await validateKustomizationPatchTargetsResolved(root, yamlFiles, fail)
4771
4854
 
4772
4855
  await validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFiles, fail, pass)
@@ -11,6 +11,14 @@
11
11
  * `mirror.gcr.io/library/{alpine,nginx,node}`.
12
12
  */
13
13
 
14
+ const FROM_LINE_RE = /^\s*FROM\s+([^\n]+)/i
15
+ const TOKEN_RE = /(?:[^\s"]+|"[^"]*")+/g
16
+ const MIRROR_GCR_RE = /^mirror\.gcr\.io\//i
17
+ const IP_LIKE_RE = /^\d+\.\d+/
18
+ const HOST_PORT_RE = /^\S+:\d+$/
19
+ const DOCKER_IO_PREFIX_RE = /^(docker\.io|index\.docker\.io)\//
20
+ const NEWLINE_SPLIT_RE = /\r?\n/
21
+
14
22
  /**
15
23
  * @param {string} t — токен образу в лапках або без
16
24
  * @returns {string} токен без зовнішніх лапок
@@ -25,18 +33,16 @@ function stripFromImageQuotes(t) {
25
33
  /**
26
34
  * Виділяє токен образу з рядка `FROM` (після зняття inline-коментаря, без AS).
27
35
  * Підтримує прапорець `--platform=…` і форму `--platform` + значення.
28
- *
29
36
  * @param {string} line — рядок Dockerfile
30
37
  * @returns {string | null} токен образу або null, якщо рядок не `FROM`
31
38
  */
32
39
  export function getFromImageToken(line) {
33
40
  const withoutComment = line.split('#')[0].trim()
34
41
  if (!withoutComment) return null
35
- const m = withoutComment.match(/^\s*FROM\s+(.+)$/i)
42
+ const m = withoutComment.match(FROM_LINE_RE)
36
43
  if (!m) return null
37
44
  const raw = m[1].trim()
38
- const tokenRe = /(?:[^\s"]+|"[^"]*")+/g
39
- const tokens = raw.match(tokenRe) || []
45
+ const tokens = raw.match(TOKEN_RE) || []
40
46
  let i = 0
41
47
  while (i < tokens.length) {
42
48
  const t = tokens[i]
@@ -64,13 +70,12 @@ export function getFromImageToken(line) {
64
70
  /**
65
71
  * Схоже на звернення до Docker Hub (коротке ім’я, `docker.io/…`, не mirror.gcr.io).
66
72
  * Не вважати Hub: явний чужий реєстр (`gcr.io/…`, `reg.example.com:5000/…`).
67
- *
68
73
  * @param {string} imageToken — ref образу (FROM)
69
74
  * @returns {boolean} true, якщо схоже на pull з Docker Hub
70
75
  */
71
76
  export function isDockerHubStyleImageRef(imageToken) {
72
77
  if (!imageToken) return false
73
- if (/^mirror\.gcr\.io\//i.test(imageToken)) return false
78
+ if (MIRROR_GCR_RE.test(imageToken)) return false
74
79
  const noDigest = imageToken.split('@')[0] || ''
75
80
  if (!noDigest.includes('/')) {
76
81
  return true
@@ -78,8 +83,8 @@ export function isDockerHubStyleImageRef(imageToken) {
78
83
  const first = noDigest.split('/')[0] || ''
79
84
  if (first === 'docker.io' || first === 'index.docker.io') return true
80
85
  if (first.includes('.')) return false
81
- if (first === 'localhost' || /^\d+\.\d+/.test(first)) return false
82
- if (first.includes(':') && /^\S+:\d+$/.test(first)) {
86
+ if (first === 'localhost' || IP_LIKE_RE.test(first)) return false
87
+ if (first.includes(':') && HOST_PORT_RE.test(first)) {
83
88
  return false
84
89
  }
85
90
  return true
@@ -87,13 +92,12 @@ export function isDockerHubStyleImageRef(imageToken) {
87
92
 
88
93
  /**
89
94
  * Нормалізує шлях репозиторію (без тега/digest) для порівняння: `library/node`, `oven/bun`, …
90
- *
91
95
  * @param {string} imageToken — ref образу
92
96
  * @returns {string} нормалізований шлях репозиторію без тега
93
97
  */
94
98
  export function normalizeHubRepoPath(imageToken) {
95
99
  let s = (imageToken.split('@')[0] || '').toLowerCase()
96
- s = s.replace(/^(docker\.io|index\.docker\.io)\//, '')
100
+ s = s.replace(DOCKER_IO_PREFIX_RE, '')
97
101
  if (!s.includes('/')) {
98
102
  return `library/${s.split(':')[0]}`
99
103
  }
@@ -105,12 +109,9 @@ export function normalizeHubRepoPath(imageToken) {
105
109
  return s
106
110
  }
107
111
 
108
- const HUB_REPOS_REQUIRING_MIRROR = /** @type {const} */ ([
109
- 'oven/bun',
110
- 'library/alpine',
111
- 'library/nginx',
112
- 'library/node'
113
- ])
112
+ const HUB_REPOS_REQUIRING_MIRROR = /** @type {const} */ (
113
+ new Set(['oven/bun', 'library/alpine', 'library/nginx', 'library/node'])
114
+ )
114
115
 
115
116
  const EXPECTED_MIRROR = /** @type {const} */ ({
116
117
  'oven/bun': 'mirror.gcr.io/oven/bun',
@@ -120,17 +121,16 @@ const EXPECTED_MIRROR = /** @type {const} */ ({
120
121
  })
121
122
 
122
123
  /**
123
- * Якщо образ тягнеть з Hub і підлягає дзеркалу — повертає рекомендовану заміну, інакше `null`.
124
- *
124
+ * Якщо образ тягнеться з Hub і підлягає дзеркалу — повертає рекомендовану заміну, інакше `null`.
125
125
  * @param {string} imageToken — ref після `FROM`
126
126
  * @returns {string | null} рекомендований `mirror.gcr.io/...` (без тега) або null
127
127
  */
128
128
  export function getRequiredMirrorGcrImage(imageToken) {
129
129
  if (!imageToken) return null
130
- if (/^mirror\.gcr\.io\//i.test(imageToken)) return null
130
+ if (MIRROR_GCR_RE.test(imageToken)) return null
131
131
  if (!isDockerHubStyleImageRef(imageToken)) return null
132
132
  const norm = normalizeHubRepoPath(imageToken)
133
- if (!HUB_REPOS_REQUIRING_MIRROR.includes(/** @type {any} */ (norm))) {
133
+ if (!HUB_REPOS_REQUIRING_MIRROR.has(/** @type {keyof typeof EXPECTED_MIRROR} */ (norm))) {
134
134
  return null
135
135
  }
136
136
  return EXPECTED_MIRROR[/** @type {keyof typeof EXPECTED_MIRROR} */ (norm)]
@@ -138,14 +138,12 @@ export function getRequiredMirrorGcrImage(imageToken) {
138
138
 
139
139
  /**
140
140
  * Сканує вміст Dockerfile / Containerfile — повертає рядок помилки або `null`.
141
- *
142
141
  * @param {string} fileContent — повний вміст Dockerfile
143
142
  * @returns {string | null} повідомлення з номером рядка або null
144
143
  */
145
144
  export function getMirrorGcrHint(fileContent) {
146
- const lines = fileContent.split(/\r?\n/)
147
- for (let n = 0; n < lines.length; n++) {
148
- const line = lines[n]
145
+ const lines = fileContent.split(NEWLINE_SPLIT_RE)
146
+ for (const [n, line] of lines.entries()) {
149
147
  const image = getFromImageToken(line)
150
148
  const expected = getRequiredMirrorGcrImage(image)
151
149
  if (expected) {