@nitra/cursor 1.8.127 → 1.8.129

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
@@ -36,7 +36,7 @@ spec:
36
36
 
37
37
  ## k8s: overlay **HTTPRoute** (**ua** / **ru**)
38
38
 
39
- За наявності **Deployment** під **k8s** і наявності **Vite** (**`vite.config.js`**, **`vite.config.mjs`** або **`vite.config.ts`** у каталозі пакета) у **`ua/kustomization.yaml`** та **`ru/kustomization.yaml`** цього пакета потрібні **inline JSON6902** у **`patches`**: **target** **`kind: HTTPRoute`**, **непорожній `name`** (як у маніфесті маршруту). Мають бути зміни **`/spec/hostnames`** (домени abie — у скрипті) та **`/spec/parentRefs/0/namespace`** (**`ua`** / **`ru`**). Для **ru** — анотація **`gwin.yandex.cloud/rules.http.upgradeTypes: websocket`** лише якщо в **тому ж** **`ru/kustomization.yaml`** є згадка **`HASURA_GRAPHQL_JWT_SECRET`** (типово patch на **ConfigMap** Hasura). Як обирати **`op`** (**add** / **replace** тощо) у patch — **k8s.mdc** (розділ про JSON patch у kustomization).
39
+ За наявності **Deployment** під **k8s** і наявності **Vite** (**`vite.config.js`**, **`vite.config.mjs`** або **`vite.config.ts`** у каталозі пакета) у **`ua/kustomization.yaml`** та **`ru/kustomization.yaml`** цього пакета потрібні **inline JSON6902** у **`patches`**: **target** **`kind: HTTPRoute`**, **непорожній `name`** (як у маніфесті маршруту). Мають бути зміни **`/spec/hostnames`** (домени abie — у скрипті) та **`/spec/parentRefs/0/namespace`** (**`ua`** / **`ru`**, також дозволені префікси **`ua-*`** / **`ru-*`**, наприклад **`ua-b2b`**, **`ru-b2b`**). Для **ru** — анотація **`gwin.yandex.cloud/rules.http.upgradeTypes: websocket`** лише якщо в **тому ж** **`ru/kustomization.yaml`** є згадка **`HASURA_GRAPHQL_JWT_SECRET`** (типово patch на **ConfigMap** Hasura). Як обирати **`op`** (**add** / **replace** тощо) у patch — **k8s.mdc** (розділ про JSON patch у kustomization).
40
40
 
41
41
  ### HTTPRoute: спільні сервіси **`auth-run-hl`**, **`file-link-hl`**
42
42
 
@@ -60,7 +60,7 @@ spec:
60
60
  port: 8080
61
61
  ```
62
62
 
63
- У **`ua/kustomization.yaml`** та **`ru/kustomization.yaml`** додай до того самого **inline** patch на **`HTTPRoute`** (той самий **`target.name`**) операції **JSON6902** з **`path`**: **`/spec/rules/<i>/backendRefs/<j>/namespace`**, де **`<i>`** / **`<j>`** — індекси відповідно до порядку **`spec.rules`** та **`backendRefs`** у base-файлі; **`value`**: **`ua`** або **`ru`**. Якщо кілька таких **`backendRefs`**, потрібна окрема операція для кожного.
63
+ У **`ua/kustomization.yaml`** та **`ru/kustomization.yaml`** додай до того самого **inline** patch на **`HTTPRoute`** (той самий **`target.name`**) операції **JSON6902** з **`path`**: **`/spec/rules/<i>/backendRefs/<j>/namespace`**, де **`<i>`** / **`<j>`** — індекси відповідно до порядку **`spec.rules`** та **`backendRefs`** у base-файлі; **`value`**: **`ua`** / **`ru`** (також дозволені **`ua-*`** / **`ru-*`**). Якщо кілька таких **`backendRefs`**, потрібна окрема операція для кожного.
64
64
 
65
65
  ```yaml title="…/ua/kustomization.yaml (фрагмент)"
66
66
  - target:
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.129",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -86,10 +86,11 @@ const PATCH_PREEM_FALSE_RE = /\bpreem:\s*['"]?false['"]?\b/u
86
86
  const PATCH_YANDEX_PREEMPTIBLE_FALSE_RE = /yandex\.cloud\/preemptible:\s*['"]?false['"]?/u
87
87
  const TRAILING_SLASH_RE = /\/$/u
88
88
  const PATCH_HOSTNAMES_PATH_RE = /path:\s*\/spec\/hostnames\b/mu
89
+ // Overlay namespaces: allow ua/ru and ua-*/ru-* (e.g. ua-b2b, ru-b2b).
89
90
  const PATCH_PARENT_REF_NS_UA_RE =
90
- /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua['"]?(?:\s|$)/mu
91
+ /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua(?:-[a-z0-9][a-z0-9-]*)?['"]?(?:\s|$)/imu
91
92
  const PATCH_PARENT_REF_NS_RU_RE =
92
- /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ru['"]?(?:\s|$)/mu
93
+ /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ru(?:-[a-z0-9][a-z0-9-]*)?['"]?(?:\s|$)/imu
93
94
  const WEBSOCKET_ANNOTATION_RE = /gwin\.yandex\.cloud\/rules\.http\.upgradeTypes:\s*['"]?websocket['"]?/mu
94
95
  const LEADING_EMPTY_LINE_RE = /^\s*\n/u
95
96
  const REMOVE_CLUSTER_IP_AFTER_OP_RE = /op:\s*remove\b[\s\S]{0,200}?path:\s*\/spec\/clusterIP\b/mu
@@ -225,6 +226,20 @@ export function isAllowedAbieBaseDevHostname(hostname) {
225
226
  return false
226
227
  }
227
228
 
229
+ /**
230
+ * @param {unknown} hostnames значення поля spec.hostnames
231
+ * @returns {string[]} непорожні рядки-хости
232
+ */
233
+ function collectAbieHostnames(hostnames) {
234
+ if (Array.isArray(hostnames)) {
235
+ return hostnames.filter(h => typeof h === 'string' && h.trim() !== '')
236
+ }
237
+ if (typeof hostnames === 'string' && hostnames.trim() !== '') {
238
+ return [hostnames]
239
+ }
240
+ return []
241
+ }
242
+
228
243
  /**
229
244
  * Повідомлення про недопустимі **spec.hostnames** у **HTTPRoute** у шляху **…/base/…** (abie.mdc).
230
245
  * @param {unknown} obj корінь YAML-документа
@@ -232,49 +247,23 @@ export function isAllowedAbieBaseDevHostname(hostname) {
232
247
  * @returns {string[]} порожньо, якщо перевірка не застосовується або hostnames коректні
233
248
  */
234
249
  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
- }
250
+ if (!isAbieK8sBaseYamlPath(rel)) return []
251
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return []
241
252
  const rec = /** @type {Record<string, unknown>} */ (obj)
242
- if (rec.kind !== 'HTTPRoute') {
243
- return []
244
- }
253
+ if (rec.kind !== 'HTTPRoute') return []
245
254
  const spec = rec.spec
246
- if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
247
- return []
248
- }
255
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return []
249
256
  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
- }
257
+ if (hostnames === undefined) return []
258
+ const hosts = collectAbieHostnames(hostnames)
259
+ if (hosts.length === 0) return []
267
260
  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(
261
+ return hosts
262
+ .filter(h => !isAllowedAbieBaseDevHostname(h))
263
+ .map(
264
+ h =>
273
265
  `${rel}: HTTPRoute у base (dev): hostname "${h}" недопустимий — дозволені лише ${root} та піддомени, зокрема *.${root} (abie.mdc)`
274
- )
275
- }
276
- }
277
- return errors
266
+ )
278
267
  }
279
268
 
280
269
  /**
@@ -1157,8 +1146,8 @@ export async function analyzeAbieSharedBackendRefsInPackageK8s(root, pkgAbs, yam
1157
1146
  function countAbieHttpRouteBackendRefNamespacePatchesInCombined(combined, mode) {
1158
1147
  const re =
1159
1148
  mode === 'ua'
1160
- ? /path:\s*\/spec\/rules\/\d+\/backendRefs\/\d+\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua['"]?(?:\s|$)/gmu
1161
- : /path:\s*\/spec\/rules\/\d+\/backendRefs\/\d+\/namespace\b[\s\S]{0,200}?value:\s*['"]?ru['"]?(?:\s|$)/gmu
1149
+ ? /path:\s*\/spec\/rules\/\d+\/backendRefs\/\d+\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua(?:-[a-z0-9][a-z0-9-]*)?['"]?(?:\s|$)/gimu
1150
+ : /path:\s*\/spec\/rules\/\d+\/backendRefs\/\d+\/namespace\b[\s\S]{0,200}?value:\s*['"]?ru(?:-[a-z0-9][a-z0-9-]*)?['"]?(?:\s|$)/gimu
1162
1151
  return [...combined.matchAll(re)].length
1163
1152
  }
1164
1153
 
@@ -1591,6 +1580,37 @@ async function checkHttpRouteKustomization(abs, rel, mode, root, yamlFilesAbs, c
1591
1580
  return true
1592
1581
  }
1593
1582
 
1583
+ /**
1584
+ * @param {unknown} json YAML-документ
1585
+ * @returns {boolean} true, якщо HTTPRoute має непорожні spec.hostnames
1586
+ */
1587
+ function httpRouteHasNonEmptyHostnames(json) {
1588
+ if (json === null || typeof json !== 'object' || Array.isArray(json)) return false
1589
+ const rec = /** @type {Record<string, unknown>} */ (json)
1590
+ if (rec.kind !== 'HTTPRoute') return false
1591
+ const spec = rec.spec
1592
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) return false
1593
+ const hostnames = /** @type {Record<string, unknown>} */ (spec).hostnames
1594
+ return collectAbieHostnames(hostnames).length > 0
1595
+ }
1596
+
1597
+ /**
1598
+ * @param {import('yaml').Document} doc YAML-документ з файлу
1599
+ * @param {string} rel відносний шлях для повідомлень
1600
+ * @param {(msg: string) => void} fail callback при помилці
1601
+ * @returns {{ hasErrors: boolean, hasHostnames: boolean }} результат обробки документа
1602
+ */
1603
+ function processBaseHttpRouteDoc(doc, rel, fail) {
1604
+ if (doc.errors.length !== 0) return { hasErrors: false, hasHostnames: false }
1605
+ const json = doc.toJSON()
1606
+ const errs = abieBaseHttpRouteHostnamesErrors(json, rel)
1607
+ if (errs.length > 0) {
1608
+ for (const e of errs) fail(e)
1609
+ return { hasErrors: true, hasHostnames: false }
1610
+ }
1611
+ return { hasErrors: false, hasHostnames: httpRouteHasNonEmptyHostnames(json) }
1612
+ }
1613
+
1594
1614
  /**
1595
1615
  * Для кожного **HTTPRoute** у **`…/k8s/base/…`** з непорожніми **`spec.hostnames`** — лише **aiml.live** та піддомени (abie.mdc).
1596
1616
  * @param {string} root корінь репозиторію
@@ -1601,39 +1621,15 @@ async function checkHttpRouteKustomization(abs, rel, mode, root, yamlFilesAbs, c
1601
1621
  */
1602
1622
  async function ensureAbieBaseHttpRouteHostnames(root, yamlFilesAbs, fail, passFn) {
1603
1623
  let baseHttpRoutesWithHostnames = 0
1604
- for (const abs of yamlFilesAbs) {
1624
+ const baseFiles = yamlFilesAbs.filter(abs => isAbieK8sBaseYamlPath(relative(root, abs).replaceAll('\\', '/') || abs))
1625
+ for (const abs of baseFiles) {
1605
1626
  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
- }
1627
+ const docs = await readAndParseYamlDocs(abs, rel, fail)
1628
+ if (!docs) return
1629
+ for (const doc of docs) {
1630
+ const { hasErrors, hasHostnames } = processBaseHttpRouteDoc(doc, rel, fail)
1631
+ if (hasErrors) return
1632
+ if (hasHostnames) baseHttpRoutesWithHostnames++
1637
1633
  }
1638
1634
  }
1639
1635
  if (baseHttpRoutesWithHostnames > 0) {
@@ -1685,7 +1681,6 @@ async function ensureUaRuAbieHttpRoutePatches(root, yamlFilesAbs, fail, passFn)
1685
1681
  /**
1686
1682
  * Перевіряє відсутність артефактів Firebase Hosting у **кожному** **підкаталозі першого рівня** від кореня
1687
1683
  * (не в самому корені репозиторію) — abie.mdc. Каталоги **`.git`** і **`node_modules`** у скануванні пропускаються.
1688
- *
1689
1684
  * @param {string} root корінь репозиторію
1690
1685
  * @param {(msg: string) => void} passFn успішне повідомлення
1691
1686
  * @param {(msg: string) => void} failFn повідомлення про порушення
@@ -1700,9 +1695,7 @@ async function ensureNoFirebaseHostingArtifacts(root, passFn, failFn) {
1700
1695
  failFn(`Не вдалося прочитати ${root} для перевірки Firebase Hosting: ${msg} (abie.mdc)`)
1701
1696
  return
1702
1697
  }
1703
- const topDirs = entries.filter(
1704
- e => e.isDirectory() && !ABIE_FIREBASE_HOSTING_SCAN_SKIP_TOP_DIR_NAMES.has(e.name)
1705
- )
1698
+ const topDirs = entries.filter(e => e.isDirectory() && !ABIE_FIREBASE_HOSTING_SCAN_SKIP_TOP_DIR_NAMES.has(e.name))
1706
1699
  let hasViolation = false
1707
1700
  for (const e of topDirs) {
1708
1701
  for (const name of ['.firebaserc', 'firebase.json']) {
@@ -1720,9 +1713,7 @@ async function ensureNoFirebaseHostingArtifacts(root, passFn, failFn) {
1720
1713
  if (hasViolation) {
1721
1714
  return
1722
1715
  }
1723
- passFn(
1724
- 'Підкаталоги кореня (1-й рівень, без .git/node_modules): артефактів Firebase Hosting не знайдено (abie.mdc)'
1725
- )
1716
+ passFn('Підкаталоги кореня (1-й рівень, без .git/node_modules): артефактів Firebase Hosting не знайдено (abie.mdc)')
1726
1717
  }
1727
1718
 
1728
1719
  /**
@@ -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) {