@nitra/cursor 1.8.125 → 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/abie.mdc CHANGED
@@ -222,7 +222,7 @@ patches:
222
222
  path: /spec/template/spec/containers/-
223
223
  value:
224
224
  name: СЕРВІС-p
225
- image: nginx:1.27-alpine
225
+ image: nginx:alpine
226
226
  ports:
227
227
  - containerPort: 8081
228
228
  protocol: TCP
package/mdc/docker.mdc CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  description: Dockerfile — lint-docker / hadolint; перевірка check-docker
3
- version: '1.7'
3
+ version: '1.8'
4
4
  globs: "**/Dockerfile*"
5
5
  alwaysApply: false
6
6
  ---
@@ -9,6 +9,15 @@ alwaysApply: false
9
9
 
10
10
  [hadolint](https://github.com/hadolint/hadolint) перевіряє Dockerfile на типові помилки та рекомендації (`FROM`, `RUN`, `COPY`, shell form тощо).
11
11
 
12
+ Для образів з Docker Hub — **`oven/bun`**, **`alpine`**, **`nginx`**, **`node`** — у **`FROM`** треба вказувати дзеркало GCR, а не pull напряму з Hub: **`mirror.gcr.io/oven/bun`**, **`mirror.gcr.io/library/alpine`**, **`mirror.gcr.io/library/nginx`**, **`mirror.gcr.io/library/node`**. Перевіряє **`check-docker.mjs`**, деталі в **`npm/scripts/utils/docker-mirror.mjs`**.
13
+
14
+ Також Dockerfile/Containerfile **має бути multistage build**: окремий build stage (залежності/компіляція) і окремий runtime stage. У фінальному stage дозволені лише мінімальні базові образи:
15
+
16
+ - **backend**: `mirror.gcr.io/library/alpine:*`
17
+ - **frontend**: `mirror.gcr.io/library/nginx:*`
18
+
19
+ Це гарантує, що результуючий образ містить лише runtime (alpine) або nginx, без build tooling і node_modules.
20
+
12
21
  ## Область
13
22
 
14
23
  - Усі файли з іменем **`Dockerfile`** або **`Dockerfile.*`** (наприклад `Dockerfile.prod`) у репозиторії, крім ігнорованих каталогів (`node_modules`, `.git`, `dist`, …) — як у **`check-docker.mjs`**.
package/mdc/ga.mdc CHANGED
@@ -149,8 +149,36 @@ jobs:
149
149
  }
150
150
  ```
151
151
 
152
+ У **`.vscode/settings.json`** для мови **`github-actions-workflow`** (workflow з розширення GitHub Actions) задай **oxc** як formatter:
153
+
154
+ ```json
155
+ "[github-actions-workflow]": {
156
+ "editor.defaultFormatter": "oxc.oxc-vscode"
157
+ }
158
+ ```
159
+
152
160
  **ЗАБОРОНЕНО** дублювати кроки встановлення Bun та кешування безпосередньо у workflow файлах. Завжди використовуй локальний composite action.
153
161
 
162
+ **Кроки `run`:** не розбивай команду shell-продовженням через зворотний сліш у кінці рядка (`… \` у `run: |`). Замість багаторядкового буквального блока з `\\` оформ довгу одну shell-команду як **folded block** `>-` (рядки з’єднаються в один рядок із пробілами).
163
+
164
+ ### Приклад run (НЕПРАВИЛЬНО — `\\` на кінцях)
165
+
166
+ ```yaml
167
+ - run: |
168
+ docker build \
169
+ --push \
170
+ --build-arg BRANCH=${{ github.ref_name }}
171
+ ```
172
+
173
+ ### Приклад run (ПРАВИЛЬНО — `>-`)
174
+
175
+ ```yaml
176
+ - run: >-
177
+ docker build
178
+ --push
179
+ --build-arg BRANCH=${{ github.ref_name }}
180
+ ```
181
+
154
182
  ### Приклад (НЕПРАВИЛЬНО)
155
183
 
156
184
  ```yaml
package/mdc/k8s.mdc CHANGED
@@ -129,7 +129,7 @@ resources:
129
129
 
130
130
  ```yaml
131
131
  # yaml-language-server: $schema=https://datreeio.github.io/CRDs-catalog/gateway.networking.k8s.io/httproute_v1beta1.json
132
- apiVersion: gateway.networking.k8s.io/v1beta1
132
+ apiVersion: gateway.networking.k8s.io/v1
133
133
  kind: HTTPRoute
134
134
  metadata:
135
135
  name: db-h
@@ -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`):
@@ -490,7 +492,7 @@ patches:
490
492
  # yaml-language-server: $schema=https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/secrets.infisical.com/infisicalsecret_v1alpha1.json
491
493
  ```
492
494
 
493
- **Приклад (Gateway API):** `apiVersion: gateway.networking.k8s.io/v1beta1`, `kind: HTTPRoute`:
495
+ **Приклад (Gateway API):** `apiVersion: gateway.networking.k8s.io/v1`, `kind: HTTPRoute`:
494
496
 
495
497
  ```yaml
496
498
  # yaml-language-server: $schema=https://datreeio.github.io/CRDs-catalog/gateway.networking.k8s.io/httproute_v1beta1.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.125",
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
  /**
@@ -1,16 +1,38 @@
1
1
  /**
2
2
  * Запускає hadolint для Dockerfile / Containerfile у всьому репозиторії (див. docker.mdc).
3
3
  *
4
+ * Додатково переконуються, що образи `oven/bun`, `alpine`, `nginx`, `node` з Docker Hub
5
+ * вказуються через `mirror.gcr.io` (див. `utils/docker-mirror.mjs`).
6
+ *
7
+ * Також перевіряє, що Dockerfile/Containerfile має **multistage build** і що фінальний stage
8
+ * використовує мінімальний runtime-образ:
9
+ * - backend: `mirror.gcr.io/library/alpine:*`
10
+ * - frontend: `mirror.gcr.io/library/nginx:*`
11
+ *
12
+ * Мета — щоб у фінальному образі не було build tooling (Bun/Node та залежностей), а лише
13
+ * runtime (alpine) або nginx.
14
+ *
4
15
  * Знаходить Dockerfile, Dockerfile.*, Containerfile, Containerfile.*; пропускає node_modules, .git
5
16
  * тощо. Спочатку hadolint з PATH, інакше docker run з образом hadolint/hadolint.
6
17
  * Кореневий .hadolint.yaml підхоплюється hadolint автоматично.
7
18
  */
19
+ import { readFile } from 'node:fs/promises'
8
20
  import { basename } from 'node:path'
9
21
 
22
+ import { getMirrorGcrHint, getFromImageToken } from './utils/docker-mirror.mjs'
10
23
  import { lintDockerfileWithHadolint, posixRel } from './utils/docker-hadolint.mjs'
11
24
  import { createCheckReporter } from './utils/check-reporter.mjs'
12
25
  import { walkDir } from './utils/walkDir.mjs'
13
26
 
27
+ const NEWLINE_RE = /\r?\n/
28
+
29
+ /**
30
+ * @typedef {{
31
+ * line: number
32
+ * image: string
33
+ * }} FromStage
34
+ */
35
+
14
36
  /**
15
37
  * Чи є basename Dockerfile / Containerfile (у т.ч. Dockerfile.prod).
16
38
  * @param {string} name basename шляху
@@ -37,6 +59,50 @@ export async function findDockerfilePaths(root) {
37
59
  return out.toSorted((a, b) => a.localeCompare(b))
38
60
  }
39
61
 
62
+ /**
63
+ * Витягує всі `FROM <image>` зі вмісту Dockerfile/Containerfile.
64
+ * @param {string} fileContent вміст Dockerfile/Containerfile
65
+ * @returns {FromStage[]} список знайдених FROM-інструкцій
66
+ */
67
+ export function parseFromStages(fileContent) {
68
+ const out = []
69
+ const lines = fileContent.split(NEWLINE_RE)
70
+ for (const [i, line] of lines.entries()) {
71
+ const image = getFromImageToken(line)
72
+ if (image) out.push({ line: i + 1, image })
73
+ }
74
+ return out
75
+ }
76
+
77
+ const RUNTIME_IMAGES = /** @type {const} */ (['mirror.gcr.io/library/alpine', 'mirror.gcr.io/library/nginx'])
78
+
79
+ /**
80
+ * Перевіряє базові вимоги до структури Dockerfile:
81
+ * - multistage: мінімум 2 FROM
82
+ * - фінальний FROM: alpine або nginx з mirror.gcr.io
83
+ * @param {string} fileContent вміст Dockerfile/Containerfile
84
+ * @returns {string | null} повідомлення помилки або null
85
+ */
86
+ export function getMultistageAndRuntimeHint(fileContent) {
87
+ const stages = parseFromStages(fileContent)
88
+ if (stages.length === 0) return null
89
+
90
+ if (stages.length < 2) {
91
+ return 'має бути multistage build: мінімум 2 інструкції FROM (build stage + runtime stage)'
92
+ }
93
+
94
+ const last = stages.at(-1)
95
+ const lastImage = (last?.image || '').split('@')[0] || ''
96
+ const lastLower = lastImage.toLowerCase()
97
+
98
+ const okRuntime = RUNTIME_IMAGES.some(img => lastLower.startsWith(`${img}:`) || lastLower === img)
99
+ if (!okRuntime) {
100
+ return `фінальний FROM має бути ${RUNTIME_IMAGES.join(' або ')} (runtime stage), зараз: ${last?.image} (рядок ${last?.line})`
101
+ }
102
+
103
+ return null
104
+ }
105
+
40
106
  /**
41
107
  * Перевіряє Dockerfile / Containerfile через hadolint (docker.mdc).
42
108
  * @returns {Promise<number>} 0 — все OK, 1 — є зауваження або помилка запуску
@@ -57,6 +123,17 @@ export async function check() {
57
123
 
58
124
  for (const abs of files) {
59
125
  const rel = posixRel(root, abs) || basename(abs)
126
+ const content = await readFile(abs, 'utf8')
127
+ const hint = getMirrorGcrHint(content)
128
+ if (hint) {
129
+ fail(`${rel} (mirror.gcr.io): ${hint}`)
130
+ }
131
+
132
+ const multistageHint = getMultistageAndRuntimeHint(content)
133
+ if (multistageHint) {
134
+ fail(`${rel} (multistage): ${multistageHint}`)
135
+ }
136
+
60
137
  const { ok, stdout, stderr, via } = lintDockerfileWithHadolint(root, abs)
61
138
  const tail = (stdout + stderr).trim()
62
139
  if (ok) {
@@ -4,11 +4,14 @@
4
4
  * Workflows лише з розширенням `.yml`, наявність clean/lint workflow, конфіг zizmor з ref-pin,
5
5
  * відсутність MegaLinter, коректний скрипт `lint-ga` у `package.json`, виклик у `lint-ga.yml`,
6
6
  * наявність composite `.github/actions/setup-bun-deps/action.yml` (його записує npx `\@nitra/cursor`),
7
+ * `\.vscode/settings.json` — `editor.defaultFormatter` **oxc** для `[github-actions-workflow]`,
7
8
  * перед `uses: ./…/setup-bun-deps` у workflow — `actions/checkout` (runner інакше не бачить локальний action).
8
9
  *
9
10
  * Заборонено дублювати кроки встановлення Bun та кешування безпосередньо у workflow файлах
10
11
  * (oven-sh/setup-bun, actions/cache, bun install). Перевірки `uses`/`run` виконуються після **YAML parse**
11
12
  * (`yaml`), щоб не спрацьовувати на випадкові збіги в коментарях або поза кроками.
13
+ *
14
+ * У `run:` заборонено shell-продовження рядків через `\\` перед переносом; довгі команди — через folded block `>-`.
12
15
  */
13
16
  import { existsSync } from 'node:fs'
14
17
  import { readdir, readFile } from 'node:fs/promises'
@@ -19,6 +22,7 @@ import {
19
22
  anyRunStepIncludes,
20
23
  eventPathsIncludeExact,
21
24
  findForbiddenUsesOrRunPatterns,
25
+ findRunStepsWithShellLineContinuationBackslash,
22
26
  hasAnyStepUsesContaining,
23
27
  hasCheckoutBeforeLocalSetupBunDeps,
24
28
  parseWorkflowYaml
@@ -117,6 +121,31 @@ function verifyNoDirectBunOrCache(relPath, content, failFn, passFn) {
117
121
  }
118
122
  }
119
123
 
124
+ /**
125
+ * У кроках `run` заборонено shell-продовження через `\\` перед переносом; замість `run: |` з `\\` використовуй `run: >-`.
126
+ * @param {string} relPath шлях для повідомлень
127
+ * @param {string} content вміст YAML
128
+ * @param {(msg: string) => void} failFn реєструє порушення (exit 1)
129
+ * @param {(msg: string) => void} passFn реєструє успішну перевірку
130
+ * @returns {void}
131
+ */
132
+ function verifyNoRunShellLineContinuationBackslash(relPath, content, failFn, passFn) {
133
+ const root = parseWorkflowYaml(content)
134
+ if (!root) {
135
+ return
136
+ }
137
+ const hits = findRunStepsWithShellLineContinuationBackslash(root)
138
+ if (hits.length === 0) {
139
+ passFn(String.raw`${relPath}: run без shell-продовження через \ (ga.mdc)`)
140
+ return
141
+ }
142
+ for (const h of hits) {
143
+ failFn(
144
+ String.raw`${relPath}: job ${h.jobId}, крок ${h.stepIndex + 1}: у run заборонено продовження рядків через зворотний сліш; довгі команди оформи як folded block (run: >-) без \ на кінцях рядків (ga.mdc)`
145
+ )
146
+ }
147
+ }
148
+
120
149
  /**
121
150
  * Перевіряє apply-workflow на наявність paths trigger.
122
151
  * @param {string} wfDir директорія workflows
@@ -183,6 +212,44 @@ async function checkZizmor(passFn, failFn) {
183
212
  }
184
213
  }
185
214
 
215
+ /**
216
+ * Перевіряє `.vscode/settings.json`: oxfmt/oxc як default formatter для GitHub Actions workflow (мова
217
+ * `github-actions-workflow` з розширення github.vscode-github-actions), узгоджено з oxc для yaml/workflow.
218
+ * @param {(msg: string) => void} passFn callback при успішній перевірці
219
+ * @param {(msg: string) => void} failFn callback при помилці
220
+ */
221
+ async function checkVscodeSettingsForGa(passFn, failFn) {
222
+ const rel = '.vscode/settings.json'
223
+ if (!existsSync(rel)) {
224
+ failFn(`${rel} не існує — додай [github-actions-workflow].editor.defaultFormatter = oxc.oxc-vscode (ga.mdc)`)
225
+ return
226
+ }
227
+ let settings
228
+ try {
229
+ settings = JSON.parse(await readFile(rel, 'utf8'))
230
+ } catch {
231
+ failFn(`${rel}: невалідний JSON (ga.mdc)`)
232
+ return
233
+ }
234
+ if (!settings || typeof settings !== 'object') {
235
+ failFn(`${rel}: очікується об’єкт налаштувань (ga.mdc)`)
236
+ return
237
+ }
238
+ const block = /** @type {Record<string, unknown>} */ (settings)['[github-actions-workflow]']
239
+ if (!block || typeof block !== 'object' || block === null || Array.isArray(block)) {
240
+ failFn(`${rel}: додай "[github-actions-workflow]": { "editor.defaultFormatter": "oxc.oxc-vscode" } (ga.mdc)`)
241
+ return
242
+ }
243
+ const df = String(/** @type {Record<string, unknown>} */ (block)['editor.defaultFormatter'] ?? '')
244
+ if (df !== 'oxc.oxc-vscode') {
245
+ failFn(
246
+ `${rel}: [github-actions-workflow].editor.defaultFormatter має бути "oxc.oxc-vscode" (зараз: ${df || '∅'}) (ga.mdc)`
247
+ )
248
+ return
249
+ }
250
+ passFn(`${rel}: [github-actions-workflow] → oxc.oxc-vscode`)
251
+ }
252
+
186
253
  /**
187
254
  * Перевіряє скрипт lint-ga в package.json.
188
255
  * @param {(msg: string) => void} passFn callback при успішній перевірці
@@ -379,6 +446,8 @@ export async function check() {
379
446
  fail('.vscode/extensions.json не існує')
380
447
  }
381
448
 
449
+ await checkVscodeSettingsForGa(pass, fail)
450
+
382
451
  const ymlWorkflows = files.filter(f => f.endsWith('.yml'))
383
452
  await checkMegalinter(wfDir, ymlWorkflows, pass, fail)
384
453
 
@@ -386,6 +455,7 @@ export async function check() {
386
455
  const content = await readFile(join(wfDir, f), 'utf8')
387
456
  verifyCheckoutBeforeLocalSetupBunDeps(`${wfDir}/${f}`, content, fail, pass)
388
457
  verifyNoDirectBunOrCache(`${wfDir}/${f}`, content, fail, pass)
458
+ verifyNoRunShellLineContinuationBackslash(`${wfDir}/${f}`, content, fail, pass)
389
459
  }
390
460
 
391
461
  await checkZizmor(pass, fail)
@@ -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,12 +469,41 @@ 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[]`**
403
504
  * (рядки-шляхи) і **`replacements[].path`**, якщо задано.
404
505
  * @param {unknown} obj корінь першого документа
405
- * @returns {string[]}
506
+ * @returns {string[]} масив локальних шляхів для перевірки існування на диску
406
507
  */
407
508
  export function kustomizePathRefsForExistenceCheck(obj) {
408
509
  if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
@@ -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,56 +566,23 @@ 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
- if (typeof r !== 'string' || r.includes('://') || r.trim() === '') {
467
- continue
468
- }
469
- const target = resolve(kustDir, r.trim())
470
- if (!resolvedFilePathIsUnderRoot(rootNorm, target)) {
471
- fail(
472
- `${rel}: посилання «${r}» виходить за межі репозиторію (resolve: ${(relative(rootNorm, target) || target).replaceAll('\\', '/')
473
- }) (k8s.mdc)`
474
- )
475
- continue
476
- }
477
- /** @type {import('node:fs').Stats | undefined} */
478
- let st
479
- try {
480
- st = await stat(target)
481
- } catch {
482
- st = undefined
483
- }
484
- if (st === undefined) {
485
- fail(
486
- `${rel}: посилання «${r}» вказує на неіснуючий ресурс (очікувано файл або каталог; k8s.mdc)`
487
- )
488
- continue
489
- }
490
- if (st.isFile()) {
491
- if (!YAML_EXTENSION_RE.test(target)) {
492
- fail(
493
- `${rel}: «${r}» — за правилами k8s у kustomization для файлів дозволені лише розширення .yaml / .yml (k8s.mdc)`
494
- )
495
- }
496
- } else if (!st.isDirectory()) {
497
- fail(`${rel}: «${r}» — ні файл, ні каталог (k8s.mdc)`)
575
+ if (typeof r === 'string' && !r.includes('://') && r.trim() !== '') {
576
+ await validateKustomizationRef(rel, r, kustDir, rootNorm, fail)
498
577
  }
499
578
  }
500
579
  }
501
580
 
502
581
  /**
503
582
  * Усі `kustomization.yaml` під `k8s`: локальні `path` / ресурси мають існувати.
504
- * @param {string} root
505
- * @param {string[]} yamlFilesAbs
506
- * @param {(msg: string) => void} fail
583
+ * @param {string} root корінь репозиторію
584
+ * @param {string[]} yamlFilesAbs абсолютні шляхи YAML-файлів у k8s
585
+ * @param {(msg: string) => void} fail callback для повідомлень про помилки
507
586
  * @returns {Promise<void>}
508
587
  */
509
588
  async function validateKustomizationPathRefsExistOnDisk(root, yamlFilesAbs, fail) {
@@ -4282,6 +4361,24 @@ function isResolvedFileUnderDirectory(dirAbs, fileAbs) {
4282
4361
  return !r.startsWith('../') && r !== '..'
4283
4362
  }
4284
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
+
4285
4382
  /**
4286
4383
  * За списку посилань kustomize повертає каталоги `.../base` з `kustomization.yaml` (наслідування base).
4287
4384
  * @param {string} kustDir каталог kustomization.yaml
@@ -4293,26 +4390,12 @@ async function k8sBaseDirsFromKustomizeResourcePathRefs(kustDir, pathRefs, rootN
4293
4390
  /** @type {string[]} */
4294
4391
  const out = []
4295
4392
  for (const ref of pathRefs) {
4296
- if (typeof ref !== 'string' || ref.includes('://') || ref.trim() === '') {
4297
- continue
4298
- }
4299
- const resolved = resolve(kustDir, ref.trim())
4300
- if (!resolvedFilePathIsUnderRoot(rootNorm, resolved)) {
4301
- continue
4302
- }
4303
- let st
4304
- try {
4305
- st = await stat(resolved)
4306
- } catch {
4307
- st = undefined
4308
- }
4309
- if (st === undefined || !st.isDirectory() || basename(resolved) !== 'base') {
4310
- continue
4311
- }
4312
- if (!existsSync(join(resolved, 'kustomization.yaml')) || !isUnderK8sPathRelToRoot(rootNorm, resolved)) {
4313
- continue
4393
+ if (typeof ref === 'string' && !ref.includes('://') && ref.trim() !== '') {
4394
+ const resolved = resolve(kustDir, ref.trim())
4395
+ if (resolvedFilePathIsUnderRoot(rootNorm, resolved) && (await isK8sBaseDir(resolved, rootNorm))) {
4396
+ out.push(resolved)
4397
+ }
4314
4398
  }
4315
- out.push(resolved)
4316
4399
  }
4317
4400
  return out
4318
4401
  }
@@ -4349,20 +4432,13 @@ async function yamlFileContainsHpaOrPdbDocument(fileAbs) {
4349
4432
  if (docs === undefined) {
4350
4433
  return false
4351
4434
  }
4352
- for (const doc of docs) {
4353
- if (doc.errors.length > 0) {
4354
- continue
4355
- }
4435
+ return docs.some(doc => {
4436
+ if (doc.errors.length > 0) return false
4356
4437
  const o = doc.toJSON()
4357
- if (o === null || typeof o !== 'object' || Array.isArray(o)) {
4358
- continue
4359
- }
4438
+ if (o === null || typeof o !== 'object' || Array.isArray(o)) return false
4360
4439
  const k = /** @type {Record<string, unknown>} */ (o).kind
4361
- if (k === 'HorizontalPodAutoscaler' || k === 'PodDisruptionBudget') {
4362
- return true
4363
- }
4364
- }
4365
- return false
4440
+ return k === 'HorizontalPodAutoscaler' || k === 'PodDisruptionBudget'
4441
+ })
4366
4442
  }
4367
4443
 
4368
4444
  /**
@@ -4378,9 +4454,7 @@ async function verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, pa
4378
4454
  const { hasDeployment, hasHpa, hasPdb } = await getTreeFlags(kustAbs)
4379
4455
  if (hasHpa || hasPdb) {
4380
4456
  if (hasDeployment) {
4381
- passFn(
4382
- `${rel}: у дереві kustomize base є HPA/PDB і Deployment (k8s.mdc)`
4383
- )
4457
+ passFn(`${rel}: у дереві kustomize base є HPA/PDB і Deployment (k8s.mdc)`)
4384
4458
  } else {
4385
4459
  fail(
4386
4460
  `${rel}: у base є HorizontalPodAutoscaler і/або PodDisruptionBudget у resources/bases/…, але дерева kustomize не містить Deployment — HPA і PDB дозволені тільки разом із Deployment (k8s.mdc)`
@@ -4398,7 +4472,7 @@ async function verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, pa
4398
4472
  * @param {Record<string, unknown>} kustObj перший документ
4399
4473
  * @param {(msg: string) => void} fail callback
4400
4474
  * @param {(msg: string) => void} passFn success
4401
- * @param {(kust: string) => Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} getTreeFlags
4475
+ * @param {(kust: string) => Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} getTreeFlags функція отримання прапорців дерева kustomize
4402
4476
  * @returns {Promise<void>}
4403
4477
  */
4404
4478
  async function verifyOverlayHpaPdbFileRefsRespectBaseDeployment(
@@ -4417,55 +4491,52 @@ async function verifyOverlayHpaPdbFileRefsRespectBaseDeployment(
4417
4491
  return
4418
4492
  }
4419
4493
 
4420
- const anyBaseHasDep = await (async () => {
4421
- for (const baseDir of baseDirs) {
4422
- const { hasDeployment: h } = await getTreeFlags(join(baseDir, 'kustomization.yaml'))
4423
- if (h) {
4424
- return true
4425
- }
4426
- }
4427
- return false
4428
- })()
4494
+ const treeFlags = await Promise.all(baseDirs.map(bd => getTreeFlags(join(bd, 'kustomization.yaml'))))
4495
+ const anyBaseHasDep = treeFlags.some(f => f.hasDeployment)
4496
+
4429
4497
  for (const ref of pathRefs) {
4430
- if (typeof ref !== 'string' || ref.includes('://') || ref.trim() === '') {
4431
- continue
4432
- }
4433
- const fAbs = resolve(kustDir, ref.trim())
4434
- if (!resolvedFilePathIsUnderRoot(root, fAbs) || !existsSync(fAbs)) {
4435
- continue
4436
- }
4437
- let st
4438
- try {
4439
- st = await stat(fAbs)
4440
- } catch {
4441
- st = undefined
4442
- }
4443
- if (st === undefined) {
4444
- continue
4445
- }
4446
- if (!st.isFile() || !YAML_EXTENSION_RE.test(fAbs)) {
4447
- continue
4448
- }
4449
- const fUnderSomeBase = baseDirs.some(bd => isResolvedFileUnderDirectory(bd, fAbs))
4450
- if (fUnderSomeBase) {
4451
- continue
4452
- }
4453
- const hpaPdb = await yamlFileContainsHpaOrPdbDocument(fAbs)
4454
- if (!hpaPdb) {
4455
- continue
4456
- }
4457
- if (!anyBaseHasDep) {
4458
- fail(
4459
- `${rel}: посилання «${ref}» містить HorizontalPodAutoscaler і/або PodDisruptionBudget, а наслідуваний k8s/base не дає у дереві Deployment — прибери HPA/PDB або додай Deployment у base (k8s.mdc)`
4460
- )
4461
- } else {
4462
- passFn(
4463
- `${rel}: overlay-файл «${(relative(root, fAbs) || ref).replaceAll('\\', '/')}» з HPA/PDB, base містить Deployment (k8s.mdc)`
4464
- )
4498
+ if (typeof ref === 'string' && !ref.includes('://') && ref.trim() !== '') {
4499
+ await checkOverlayRefHpaPdb(root, kustDir, rel, ref, baseDirs, anyBaseHasDep, fail, passFn)
4465
4500
  }
4466
4501
  }
4467
4502
  }
4468
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
+
4469
4540
  /**
4470
4541
  * Перевіряє всі кастомізації: (1) у k8s/base дереві HPA/PDB тільки з Deployment; (2) overlay, що
4471
4542
  * посилається на base, не додає HPA/PDB без Deployment у base.
@@ -4480,8 +4551,8 @@ async function validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFilesAbs,
4480
4551
  /** @type {Map<string, Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>>} */
4481
4552
  const treeFlagsMemo = new Map()
4482
4553
  /**
4483
- * @param {string} kustPath
4484
- * @returns {Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>}
4554
+ * @param {string} kustPath абсолютний шлях до kustomization.yaml
4555
+ * @returns {Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} прапорці наявності ресурсів у дереві
4485
4556
  */
4486
4557
  const getTreeFlags = kustPath => {
4487
4558
  const k = resolve(kustPath)
@@ -4496,21 +4567,12 @@ async function validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFilesAbs,
4496
4567
  for (const kustAbs of kustFiles) {
4497
4568
  const rel = (relative(rootNorm, kustAbs) || kustAbs).replaceAll('\\', '/')
4498
4569
  const kust = await readFirstYamlObject(kustAbs)
4499
- if (kust === null) {
4500
- continue
4501
- }
4502
- if (isK8sBaseKustomizationRelPath(rel)) {
4503
- await verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, passFn, getTreeFlags)
4504
- } else {
4505
- await verifyOverlayHpaPdbFileRefsRespectBaseDeployment(
4506
- rootNorm,
4507
- kustAbs,
4508
- rel,
4509
- kust,
4510
- fail,
4511
- passFn,
4512
- getTreeFlags
4513
- )
4570
+ if (kust !== null) {
4571
+ if (isK8sBaseKustomizationRelPath(rel)) {
4572
+ await verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, passFn, getTreeFlags)
4573
+ } else {
4574
+ await verifyOverlayHpaPdbFileRefsRespectBaseDeployment(rootNorm, kustAbs, rel, kust, fail, passFn, getTreeFlags)
4575
+ }
4514
4576
  }
4515
4577
  }
4516
4578
  }
@@ -4786,6 +4848,8 @@ export async function check() {
4786
4848
 
4787
4849
  await validateKustomizationPathRefsExistOnDisk(root, yamlFiles, fail)
4788
4850
 
4851
+ await validateKustomizationResourcesSortedAlphabetically(root, yamlFiles, fail)
4852
+
4789
4853
  await validateKustomizationPatchTargetsResolved(root, yamlFiles, fail)
4790
4854
 
4791
4855
  await validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFiles, fail, pass)
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Визначає, чи рядок `FROM` у Dockerfile використовує образи
3
+ * `oven/bun`, `alpine`, `nginx`, `node` з Docker Hub без дзеркала
4
+ * `mirror.gcr.io` (GCR mirror).
5
+ *
6
+ * Правило застосовується лише до тих самих звернень, що виглядають як
7
+ * pull з Docker Hub (короткі імена, `docker.io/…`); приватні реєстри
8
+ * (hostname у першому сегменті) не оцінюються.
9
+ *
10
+ * Канонічні заміни: `mirror.gcr.io/oven/bun` та
11
+ * `mirror.gcr.io/library/{alpine,nginx,node}`.
12
+ */
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
+
22
+ /**
23
+ * @param {string} t — токен образу в лапках або без
24
+ * @returns {string} токен без зовнішніх лапок
25
+ */
26
+ function stripFromImageQuotes(t) {
27
+ if (t.length >= 2 && (t[0] === '"' || t[0] === "'")) {
28
+ return t.slice(1, -1)
29
+ }
30
+ return t
31
+ }
32
+
33
+ /**
34
+ * Виділяє токен образу з рядка `FROM` (після зняття inline-коментаря, без AS).
35
+ * Підтримує прапорець `--platform=…` і форму `--platform` + значення.
36
+ * @param {string} line — рядок Dockerfile
37
+ * @returns {string | null} токен образу або null, якщо рядок не `FROM`
38
+ */
39
+ export function getFromImageToken(line) {
40
+ const withoutComment = line.split('#')[0].trim()
41
+ if (!withoutComment) return null
42
+ const m = withoutComment.match(FROM_LINE_RE)
43
+ if (!m) return null
44
+ const raw = m[1].trim()
45
+ const tokens = raw.match(TOKEN_RE) || []
46
+ let i = 0
47
+ while (i < tokens.length) {
48
+ const t = tokens[i]
49
+ if (t === '--platform' || t.startsWith('--platform=')) {
50
+ if (t.startsWith('--platform=')) {
51
+ i += 1
52
+ } else if (tokens[i + 1] === undefined) {
53
+ i += 1
54
+ } else {
55
+ i += 2
56
+ }
57
+ } else if (t === '--' || t.toUpperCase() === 'AS') {
58
+ break
59
+ } else if (t.startsWith('--') && t.includes('=')) {
60
+ i += 1
61
+ } else if (t.startsWith('--')) {
62
+ i += 1
63
+ } else {
64
+ return stripFromImageQuotes(t)
65
+ }
66
+ }
67
+ return null
68
+ }
69
+
70
+ /**
71
+ * Схоже на звернення до Docker Hub (коротке ім’я, `docker.io/…`, не mirror.gcr.io).
72
+ * Не вважати Hub: явний чужий реєстр (`gcr.io/…`, `reg.example.com:5000/…`).
73
+ * @param {string} imageToken — ref образу (FROM)
74
+ * @returns {boolean} true, якщо схоже на pull з Docker Hub
75
+ */
76
+ export function isDockerHubStyleImageRef(imageToken) {
77
+ if (!imageToken) return false
78
+ if (MIRROR_GCR_RE.test(imageToken)) return false
79
+ const noDigest = imageToken.split('@')[0] || ''
80
+ if (!noDigest.includes('/')) {
81
+ return true
82
+ }
83
+ const first = noDigest.split('/')[0] || ''
84
+ if (first === 'docker.io' || first === 'index.docker.io') return true
85
+ if (first.includes('.')) return false
86
+ if (first === 'localhost' || IP_LIKE_RE.test(first)) return false
87
+ if (first.includes(':') && HOST_PORT_RE.test(first)) {
88
+ return false
89
+ }
90
+ return true
91
+ }
92
+
93
+ /**
94
+ * Нормалізує шлях репозиторію (без тега/digest) для порівняння: `library/node`, `oven/bun`, …
95
+ * @param {string} imageToken — ref образу
96
+ * @returns {string} нормалізований шлях репозиторію без тега
97
+ */
98
+ export function normalizeHubRepoPath(imageToken) {
99
+ let s = (imageToken.split('@')[0] || '').toLowerCase()
100
+ s = s.replace(DOCKER_IO_PREFIX_RE, '')
101
+ if (!s.includes('/')) {
102
+ return `library/${s.split(':')[0]}`
103
+ }
104
+ const lastSl = s.lastIndexOf('/')
105
+ const lastCol = s.lastIndexOf(':')
106
+ if (lastCol > lastSl) {
107
+ s = s.slice(0, lastCol)
108
+ }
109
+ return s
110
+ }
111
+
112
+ const HUB_REPOS_REQUIRING_MIRROR = /** @type {const} */ (
113
+ new Set(['oven/bun', 'library/alpine', 'library/nginx', 'library/node'])
114
+ )
115
+
116
+ const EXPECTED_MIRROR = /** @type {const} */ ({
117
+ 'oven/bun': 'mirror.gcr.io/oven/bun',
118
+ 'library/alpine': 'mirror.gcr.io/library/alpine',
119
+ 'library/nginx': 'mirror.gcr.io/library/nginx',
120
+ 'library/node': 'mirror.gcr.io/library/node'
121
+ })
122
+
123
+ /**
124
+ * Якщо образ тягнеться з Hub і підлягає дзеркалу — повертає рекомендовану заміну, інакше `null`.
125
+ * @param {string} imageToken — ref після `FROM`
126
+ * @returns {string | null} рекомендований `mirror.gcr.io/...` (без тега) або null
127
+ */
128
+ export function getRequiredMirrorGcrImage(imageToken) {
129
+ if (!imageToken) return null
130
+ if (MIRROR_GCR_RE.test(imageToken)) return null
131
+ if (!isDockerHubStyleImageRef(imageToken)) return null
132
+ const norm = normalizeHubRepoPath(imageToken)
133
+ if (!HUB_REPOS_REQUIRING_MIRROR.has(/** @type {keyof typeof EXPECTED_MIRROR} */ (norm))) {
134
+ return null
135
+ }
136
+ return EXPECTED_MIRROR[/** @type {keyof typeof EXPECTED_MIRROR} */ (norm)]
137
+ }
138
+
139
+ /**
140
+ * Сканує вміст Dockerfile / Containerfile — повертає рядок помилки або `null`.
141
+ * @param {string} fileContent — повний вміст Dockerfile
142
+ * @returns {string | null} повідомлення з номером рядка або null
143
+ */
144
+ export function getMirrorGcrHint(fileContent) {
145
+ const lines = fileContent.split(NEWLINE_SPLIT_RE)
146
+ for (const [n, line] of lines.entries()) {
147
+ const image = getFromImageToken(line)
148
+ const expected = getRequiredMirrorGcrImage(image)
149
+ if (expected) {
150
+ return `рядок ${n + 1}: FROM має тягнути ${expected} (замість ${image})`
151
+ }
152
+ }
153
+ return null
154
+ }
@@ -3,6 +3,8 @@
3
3
  *
4
4
  * Використовується в check-ga, check-js-lint, check-text, check-style-lint, check-npm-module замість
5
5
  * пошуку підрядків у сирому тексті там, де важливі лише значення `uses:` та `run:` кроків.
6
+ *
7
+ * Для `run:` також виявляється shell-продовження рядка через `\\` перед переносом (антипатерн у ga.mdc).
6
8
  */
7
9
  import { parse } from 'yaml'
8
10
 
@@ -67,6 +69,36 @@ export function getStepRun(step) {
67
69
  return ''
68
70
  }
69
71
 
72
+ /** У тексті `run:` зіставляє `\\` одразу перед переносом рядка (типове shell-продовження в bash). */
73
+ const RUN_SHELL_LINE_CONTINUATION_BACKSLASH_RE = /\\\r?\n/
74
+
75
+ /**
76
+ * Чи містить значення `run:` shell-продовження рядка через зворотний сліш перед переносом (`… \\` + NL).
77
+ * У workflow такі конструкції замінюють на folded block `>-` без зворотних слішів (ga.mdc).
78
+ * @param {string} runText текст з `getStepRun`
79
+ * @returns {boolean} `true`, якщо знайдено `\\` перед новим рядком
80
+ */
81
+ export function runTextHasShellLineContinuationBackslash(runText) {
82
+ return typeof runText === 'string' && runText.length > 0 && RUN_SHELL_LINE_CONTINUATION_BACKSLASH_RE.test(runText)
83
+ }
84
+
85
+ /**
86
+ * Повертає кроки, у яких `run:` містить заборонене shell-продовження через `\\`.
87
+ * @param {Record<string, unknown>} root корінь workflow
88
+ * @returns {{ jobId: string, stepIndex: number }[]} список кроків із порушенням
89
+ */
90
+ export function findRunStepsWithShellLineContinuationBackslash(root) {
91
+ /** @type {{ jobId: string, stepIndex: number }[]} */
92
+ const out = []
93
+ for (const { jobId, stepIndex, step } of flattenWorkflowSteps(root)) {
94
+ const run = getStepRun(step)
95
+ if (runTextHasShellLineContinuationBackslash(run)) {
96
+ out.push({ jobId, stepIndex })
97
+ }
98
+ }
99
+ return out
100
+ }
101
+
70
102
  /**
71
103
  * Чи є крок, у якого `uses` містить будь-який з підрядків.
72
104
  * @param {Record<string, unknown>} root корінь workflow