@nitra/cursor 1.8.124 → 1.8.127

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
@@ -294,6 +294,10 @@ data:
294
294
  - **`pdb.yaml`** — `policy/v1`, `PodDisruptionBudget`, `spec.selector.matchLabels.app` **= `spec.selector.matchLabels.app`** Deployment.
295
295
  - **`topologySpreadConstraints`** — запис з `maxSkew: 1`, `topologyKey: kubernetes.io/hostname`, `whenUnsatisfiable: ScheduleAnyway`, `labelSelector.matchLabels.app` рівне тій самій мітці `app`.
296
296
 
297
+ **Kustomize `base` і overlay:** у дереві, зібраному з `k8s/…/base/kustomization.yaml` (`resources` / `bases` / `components` / `crds`, рекурсивно), **HorizontalPodAutoscaler** і **PodDisruptionBudget** допустимі **лише якщо** в тому ж дереві kustomize є **Deployment**. У `kustomization.yaml` overlay, який підключає цей `base` (наприклад, `../base` у `resources`), не додавай окремі YAML-файли з HPA / PDB, доки в наслідуваному `base` у дереві не з’явиться `Deployment`. Перевіряє **`check-k8s.mjs`**.
298
+
299
+ **Локальні шляхи в `kustomization.yaml`:** кожен запис без `://` (remote) з `resources` / `bases` / `components` / `crds`, `patchesStrategicMerge`, `patches[].path`, `patchesJson6902[].path`, `configurations[]`, `replacements[].path` має вказувати на **існуючий** у репозиторії файл (`.yaml` / `.yml`) або **каталог**; биті посилання — помилка **`check k8s`**.
300
+
297
301
  ### Env-залежні межі (за сегментом після `/k8s/`)
298
302
 
299
303
  **Dev-like середовища** — сегмент `base`, `dev`, або з суфіксом `-qa` (напр. `tr-qa`):
@@ -486,7 +490,7 @@ patches:
486
490
  # yaml-language-server: $schema=https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/secrets.infisical.com/infisicalsecret_v1alpha1.json
487
491
  ```
488
492
 
489
- **Приклад (Gateway API):** `apiVersion: gateway.networking.k8s.io/v1beta1`, `kind: HTTPRoute`:
493
+ **Приклад (Gateway API):** `apiVersion: gateway.networking.k8s.io/v1`, `kind: HTTPRoute`:
490
494
 
491
495
  ```yaml
492
496
  # 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.124",
3
+ "version": "1.8.127",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -1,16 +1,36 @@
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
+ /**
28
+ * @typedef {{
29
+ * line: number
30
+ * image: string
31
+ * }} FromStage
32
+ */
33
+
14
34
  /**
15
35
  * Чи є basename Dockerfile / Containerfile (у т.ч. Dockerfile.prod).
16
36
  * @param {string} name basename шляху
@@ -37,6 +57,55 @@ export async function findDockerfilePaths(root) {
37
57
  return out.toSorted((a, b) => a.localeCompare(b))
38
58
  }
39
59
 
60
+ /**
61
+ * Витягує всі `FROM <image>` зі вмісту Dockerfile/Containerfile.
62
+ *
63
+ * @param {string} fileContent вміст Dockerfile/Containerfile
64
+ * @returns {FromStage[]} список знайдених FROM-інструкцій
65
+ */
66
+ export function parseFromStages(fileContent) {
67
+ const out = []
68
+ const lines = fileContent.split(/\r?\n/)
69
+ for (let i = 0; i < lines.length; i++) {
70
+ const image = getFromImageToken(lines[i])
71
+ if (image) out.push({ line: i + 1, image })
72
+ }
73
+ return out
74
+ }
75
+
76
+ const RUNTIME_IMAGES = /** @type {const} */ ([
77
+ 'mirror.gcr.io/library/alpine',
78
+ 'mirror.gcr.io/library/nginx'
79
+ ])
80
+
81
+ /**
82
+ * Перевіряє базові вимоги до структури Dockerfile:
83
+ * - multistage: мінімум 2 FROM
84
+ * - фінальний FROM: alpine або nginx з mirror.gcr.io
85
+ *
86
+ * @param {string} fileContent вміст Dockerfile/Containerfile
87
+ * @returns {string | null} повідомлення помилки або null
88
+ */
89
+ export function getMultistageAndRuntimeHint(fileContent) {
90
+ const stages = parseFromStages(fileContent)
91
+ if (stages.length === 0) return null
92
+
93
+ if (stages.length < 2) {
94
+ return 'має бути multistage build: мінімум 2 інструкції FROM (build stage + runtime stage)'
95
+ }
96
+
97
+ const last = stages.at(-1)
98
+ const lastImage = (last?.image || '').split('@')[0] || ''
99
+ const lastLower = lastImage.toLowerCase()
100
+
101
+ const okRuntime = RUNTIME_IMAGES.some(img => lastLower.startsWith(`${img}:`) || lastLower === img)
102
+ if (!okRuntime) {
103
+ return `фінальний FROM має бути ${RUNTIME_IMAGES.join(' або ')} (runtime stage), зараз: ${last?.image} (рядок ${last?.line})`
104
+ }
105
+
106
+ return null
107
+ }
108
+
40
109
  /**
41
110
  * Перевіряє Dockerfile / Containerfile через hadolint (docker.mdc).
42
111
  * @returns {Promise<number>} 0 — все OK, 1 — є зауваження або помилка запуску
@@ -57,6 +126,17 @@ export async function check() {
57
126
 
58
127
  for (const abs of files) {
59
128
  const rel = posixRel(root, abs) || basename(abs)
129
+ const content = await readFile(abs, 'utf8')
130
+ const hint = getMirrorGcrHint(content)
131
+ if (hint) {
132
+ fail(`${rel} (mirror.gcr.io): ${hint}`)
133
+ }
134
+
135
+ const multistageHint = getMultistageAndRuntimeHint(content)
136
+ if (multistageHint) {
137
+ fail(`${rel} (multistage): ${multistageHint}`)
138
+ }
139
+
60
140
  const { ok, stdout, stderr, via } = lintDockerfileWithHadolint(root, abs)
61
141
  const tail = (stdout + stderr).trim()
62
142
  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(`${relPath}: run без shell-продовження через \\ (ga.mdc)`)
140
+ return
141
+ }
142
+ for (const h of hits) {
143
+ failFn(
144
+ `${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,46 @@ 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(
241
+ `${rel}: додай "[github-actions-workflow]": { "editor.defaultFormatter": "oxc.oxc-vscode" } (ga.mdc)`
242
+ )
243
+ return
244
+ }
245
+ const df = String(/** @type {Record<string, unknown>} */ (block)['editor.defaultFormatter'] ?? '')
246
+ if (df !== 'oxc.oxc-vscode') {
247
+ failFn(
248
+ `${rel}: [github-actions-workflow].editor.defaultFormatter має бути "oxc.oxc-vscode" (зараз: ${df || '∅'}) (ga.mdc)`
249
+ )
250
+ return
251
+ }
252
+ passFn(`${rel}: [github-actions-workflow] → oxc.oxc-vscode`)
253
+ }
254
+
186
255
  /**
187
256
  * Перевіряє скрипт lint-ga в package.json.
188
257
  * @param {(msg: string) => void} passFn callback при успішній перевірці
@@ -379,6 +448,8 @@ export async function check() {
379
448
  fail('.vscode/extensions.json не існує')
380
449
  }
381
450
 
451
+ await checkVscodeSettingsForGa(pass, fail)
452
+
382
453
  const ymlWorkflows = files.filter(f => f.endsWith('.yml'))
383
454
  await checkMegalinter(wfDir, ymlWorkflows, pass, fail)
384
455
 
@@ -386,6 +457,7 @@ export async function check() {
386
457
  const content = await readFile(join(wfDir, f), 'utf8')
387
458
  verifyCheckoutBeforeLocalSetupBunDeps(`${wfDir}/${f}`, content, fail, pass)
388
459
  verifyNoDirectBunOrCache(`${wfDir}/${f}`, content, fail, pass)
460
+ verifyNoRunShellLineContinuationBackslash(`${wfDir}/${f}`, content, fail, pass)
389
461
  }
390
462
 
391
463
  await checkZizmor(pass, fail)
@@ -79,6 +79,17 @@
79
79
  * з (`>=2`, `>=2`, `>=1`) не залишалися на dev-значеннях із base. Формат patch — JSON6902 або Strategic Merge;
80
80
  * наявність перевіряється через `kustomizationPatchPathsByTargetKind` (конкретне значення — у вмісті patch,
81
81
  * яке буде оцінено під час збірки Kustomize).
82
+ *
83
+ * **Існування шляхів у `kustomization.yaml`:** кожне локальне посилання (без `://`) з `resources` / `bases` /
84
+ * `components` / `crds`, `patchesStrategicMerge`, `patches[].path`, `patchesJson6902[].path`, `configurations[]`,
85
+ * `replacements[].path` має вказувати на наявний у репозиторії файл (`.yaml` / `.yml`) або каталог; інакше
86
+ * помилка `check k8s` (k8s.mdc).
87
+ *
88
+ * **HPA / PDB тільки з Deployment у `base`:** у дереві Kustomize з `…/k8s/…/base/kustomization.yaml` не
89
+ * дозволяти `HorizontalPodAutoscaler` / `PodDisruptionBudget` у `resources` / `bases` / `components` / `crds`
90
+ * (рекурсивно), якщо в цьому ж дереві немає `Deployment`. У `kustomization.yaml` overlay, який підключає
91
+ * каталог `…/k8s/…/base`, не додавай окремі YAML-файли з HPA / PDB, поки в наслідуваному `base` у дереві
92
+ * не з’явиться `Deployment` (k8s.mdc).
82
93
  */
83
94
  import { existsSync } from 'node:fs'
84
95
  import { readFile, readdir, stat, unlink } from 'node:fs/promises'
@@ -386,6 +397,119 @@ function pathsFromKustomizationObject(obj) {
386
397
  return out
387
398
  }
388
399
 
400
+ /**
401
+ * Унікальні локальні шляхи з `kustomization.yaml` для перевірки існування на диску:
402
+ * як у `pathsFromKustomizationObject`, плюс **`patchesJson6902[].path`**, плюс **`configurations[]`**
403
+ * (рядки-шляхи) і **`replacements[].path`**, якщо задано.
404
+ * @param {unknown} obj корінь першого документа
405
+ * @returns {string[]} масив локальних шляхів для перевірки існування на диску
406
+ */
407
+ export function kustomizePathRefsForExistenceCheck(obj) {
408
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
409
+ return []
410
+ }
411
+ const fromPaths = pathsFromKustomizationObject(obj)
412
+ const rec = /** @type {Record<string, unknown>} */ (obj)
413
+ const pj = rec.patchesJson6902
414
+ if (Array.isArray(pj)) {
415
+ for (const item of pj) {
416
+ if (item !== null && typeof item === 'object' && !Array.isArray(item)) {
417
+ const pth = /** @type {Record<string, unknown>} */ (item).path
418
+ if (typeof pth === 'string' && pth.trim() !== '') {
419
+ fromPaths.push(pth.trim())
420
+ }
421
+ }
422
+ }
423
+ }
424
+ const configurations = rec.configurations
425
+ if (Array.isArray(configurations)) {
426
+ for (const c of configurations) {
427
+ if (typeof c === 'string' && c.trim() !== '') {
428
+ fromPaths.push(c.trim())
429
+ }
430
+ }
431
+ }
432
+ const replacements = rec.replacements
433
+ if (Array.isArray(replacements)) {
434
+ for (const r of replacements) {
435
+ if (r !== null && typeof r === 'object' && !Array.isArray(r)) {
436
+ const pth = /** @type {Record<string, unknown>} */ (r).path
437
+ if (typeof pth === 'string' && pth.trim() !== '') {
438
+ fromPaths.push(pth.trim())
439
+ }
440
+ }
441
+ }
442
+ }
443
+ return [...new Set(fromPaths)]
444
+ }
445
+
446
+ /**
447
+ * Перевіряє, що всі перелічені в `kustomization.yaml` локальні шляхи існують.
448
+ * @param {string} root корінь репо
449
+ * @param {string} kustAbs kustomization.yaml
450
+ * @param {string} rootNorm нормалізований корінь
451
+ * @param {(msg: string) => void} fail callback
452
+ * @returns {Promise<void>}
453
+ */
454
+ async function validateOneKustomizationPathRefsExist(root, kustAbs, rootNorm, fail) {
455
+ const rel = (relative(root, kustAbs) || kustAbs).replaceAll('\\', '/')
456
+ const kust = await readFirstYamlObject(kustAbs)
457
+ if (kust === null) {
458
+ return
459
+ }
460
+ if (kust.kind !== 'Kustomization') {
461
+ return
462
+ }
463
+ const refs = kustomizePathRefsForExistenceCheck(kust)
464
+ const kustDir = dirname(resolve(kustAbs))
465
+ for (const r of refs) {
466
+ if (typeof r === 'string' && !r.includes('://') && r.trim() !== '') {
467
+ 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
+ }
495
+ }
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Усі `kustomization.yaml` під `k8s`: локальні `path` / ресурси мають існувати.
501
+ * @param {string} root корінь репозиторію
502
+ * @param {string[]} yamlFilesAbs абсолютні шляхи YAML-файлів у k8s
503
+ * @param {(msg: string) => void} fail callback для повідомлень про помилки
504
+ * @returns {Promise<void>}
505
+ */
506
+ async function validateKustomizationPathRefsExistOnDisk(root, yamlFilesAbs, fail) {
507
+ const rootNorm = resolve(root)
508
+ for (const kustAbs of yamlFilesAbs.filter(p => basename(p).toLowerCase() === 'kustomization.yaml')) {
509
+ await validateOneKustomizationPathRefsExist(root, kustAbs, rootNorm, fail)
510
+ }
511
+ }
512
+
389
513
  /**
390
514
  * Чи для кожного посилання kustomization на файл **`svc.yaml`** у списку є посилання на sibling **`svc-hl.yaml`**
391
515
  * (той самий каталог після **`resolve`** відносно каталогу **`kustomization.yaml`**).
@@ -4107,6 +4231,271 @@ async function readFirstYamlObject(absPath) {
4107
4231
  return null
4108
4232
  }
4109
4233
 
4234
+ /**
4235
+ * Чи відносний шлях вказує на `k8s/…/base/kustomization.yaml` (каталог `base` у дереві k8s).
4236
+ * @param {string} rel POSIX-шлях
4237
+ * @returns {boolean} true, якщо батьківський каталог — `…/…/base` у шляху з `k8s`
4238
+ */
4239
+ function isK8sBaseKustomizationRelPath(rel) {
4240
+ const n = rel.replaceAll('\\', '/')
4241
+ const d = dirname(n).replaceAll('\\', '/')
4242
+ if (basename(d) !== 'base') {
4243
+ return false
4244
+ }
4245
+ return d.startsWith('k8s/') || d.includes('/k8s/')
4246
+ }
4247
+
4248
+ /**
4249
+ * Чи абсолютний шлях до каталогу — k8s-`base` (ідентифікуємо за тим, що `relative` від кореня
4250
+ * містить сегмент `k8s` і basename каталогу — `base`).
4251
+ * @param {string} rootNorm нормалізований корінь репо
4252
+ * @param {string} dirAbs абсолютний шлях до каталогу
4253
+ * @returns {boolean} true для `.../k8s/.../base` з `kustomization.yaml` у цьому каталозі
4254
+ */
4255
+ function isUnderK8sPathRelToRoot(rootNorm, dirAbs) {
4256
+ const rel = (relative(rootNorm, dirAbs) || '.').replaceAll('\\', '/')
4257
+ if (rel === '' || rel === '.') {
4258
+ return false
4259
+ }
4260
+ if (rel.startsWith('../') || rel === '..') {
4261
+ return false
4262
+ }
4263
+ return rel === 'k8s' || rel.startsWith('k8s/') || rel.includes('/k8s/')
4264
+ }
4265
+
4266
+ /**
4267
+ * Чи файловий шлях усередині `dirAbs` (або збігається).
4268
+ * @param {string} dirAbs каталог
4269
+ * @param {string} fileAbs файл
4270
+ * @returns {boolean} true, якщо файл — піддерево каталогу
4271
+ */
4272
+ function isResolvedFileUnderDirectory(dirAbs, fileAbs) {
4273
+ const b = resolve(dirAbs)
4274
+ const f = resolve(fileAbs)
4275
+ const r = relative(b, f).replaceAll('\\', '/')
4276
+ if (r === '' || r === '.') {
4277
+ return true
4278
+ }
4279
+ return !r.startsWith('../') && r !== '..'
4280
+ }
4281
+
4282
+ /**
4283
+ * За списку посилань kustomize повертає каталоги `.../base` з `kustomization.yaml` (наслідування base).
4284
+ * @param {string} kustDir каталог kustomization.yaml
4285
+ * @param {string[]} pathRefs тільки resources / bases / components / crds
4286
+ * @param {string} rootNorm нормалізований корінь репо
4287
+ * @returns {Promise<string[]>} абсолютні шляхи (без дедуплікації, якщо кілька однакових ref)
4288
+ */
4289
+ async function k8sBaseDirsFromKustomizeResourcePathRefs(kustDir, pathRefs, rootNorm) {
4290
+ /** @type {string[]} */
4291
+ const out = []
4292
+ for (const ref of pathRefs) {
4293
+ if (typeof ref === 'string' && !ref.includes('://') && ref.trim() !== '') {
4294
+ 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
+ }
4311
+ }
4312
+ }
4313
+ }
4314
+ return out
4315
+ }
4316
+
4317
+ /**
4318
+ * Аналізує `resources` / `bases` / `components` / `crds` kustomization: чи в дереві є
4319
+ * `Deployment` / HPA / PDB.
4320
+ * @param {string} kustAbs kustomization.yaml
4321
+ * @param {string} rootNorm корінь
4322
+ * @returns {Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} прапорці
4323
+ */
4324
+ export async function kustomizeResourceTreeHpaPdbDeploymentFlags(kustAbs, rootNorm) {
4325
+ /** @type {Set<string>} */
4326
+ const visitedKustomization = new Set()
4327
+ const desc = await collectResourceDescriptorsForKustomizationWalk(kustAbs, rootNorm, visitedKustomization)
4328
+ return {
4329
+ hasDeployment: desc.some(d => d.kind === 'Deployment'),
4330
+ hasHpa: desc.some(d => d.kind === 'HorizontalPodAutoscaler'),
4331
+ hasPdb: desc.some(d => d.kind === 'PodDisruptionBudget')
4332
+ }
4333
+ }
4334
+
4335
+ /**
4336
+ * Чи серед документів YAML-файлу є `HorizontalPodAutoscaler` або `PodDisruptionBudget`.
4337
+ * @param {string} fileAbs абсолютний шлях
4338
+ * @returns {Promise<boolean>} true, якщо такі kind знайдені
4339
+ */
4340
+ async function yamlFileContainsHpaOrPdbDocument(fileAbs) {
4341
+ const raw = await tryReadFileUtf8(fileAbs)
4342
+ if (raw === undefined) {
4343
+ return false
4344
+ }
4345
+ const docs = tryParseAllYamlDocs(raw)
4346
+ if (docs === undefined) {
4347
+ return false
4348
+ }
4349
+ return docs.some(doc => {
4350
+ if (doc.errors.length > 0) return false
4351
+ const o = doc.toJSON()
4352
+ if (o === null || typeof o !== 'object' || Array.isArray(o)) return false
4353
+ const k = /** @type {Record<string, unknown>} */ (o).kind
4354
+ return k === 'HorizontalPodAutoscaler' || k === 'PodDisruptionBudget'
4355
+ })
4356
+ }
4357
+
4358
+ /**
4359
+ * Для `…/k8s/…/base/kustomization.yaml`: HPA / PDB дозволені в дереві kustomize лише разом із Deployment.
4360
+ * @param {string} kustAbs kustomization.yaml
4361
+ * @param {string} rel для повідомлень
4362
+ * @param {(msg: string) => void} fail callback
4363
+ * @param {(msg: string) => void} passFn success
4364
+ * @param {(kust: string) => Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} getTreeFlags мемоізований аналіз дерева
4365
+ * @returns {Promise<void>}
4366
+ */
4367
+ async function verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, passFn, getTreeFlags) {
4368
+ const { hasDeployment, hasHpa, hasPdb } = await getTreeFlags(kustAbs)
4369
+ if (hasHpa || hasPdb) {
4370
+ if (hasDeployment) {
4371
+ passFn(
4372
+ `${rel}: у дереві kustomize base є HPA/PDB і Deployment (k8s.mdc)`
4373
+ )
4374
+ } else {
4375
+ fail(
4376
+ `${rel}: у base є HorizontalPodAutoscaler і/або PodDisruptionBudget у resources/bases/…, але дерева kustomize не містить Deployment — HPA і PDB дозволені тільки разом із Deployment (k8s.mdc)`
4377
+ )
4378
+ }
4379
+ }
4380
+ }
4381
+
4382
+ /**
4383
+ * `kustomization` overlay, що посилається на `…/k8s/…/base`, не може додавати HPA / PDB як окремі YAML,
4384
+ * поки в наслідуваному base немає Deployment.
4385
+ * @param {string} root нормалізований корінь репо
4386
+ * @param {string} kustAbs kustomization.yaml
4387
+ * @param {string} rel для повідомлень
4388
+ * @param {Record<string, unknown>} kustObj перший документ
4389
+ * @param {(msg: string) => void} fail callback
4390
+ * @param {(msg: string) => void} passFn success
4391
+ * @param {(kust: string) => Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} getTreeFlags функція отримання прапорців дерева kustomize
4392
+ * @returns {Promise<void>}
4393
+ */
4394
+ async function verifyOverlayHpaPdbFileRefsRespectBaseDeployment(
4395
+ root,
4396
+ kustAbs,
4397
+ rel,
4398
+ kustObj,
4399
+ fail,
4400
+ passFn,
4401
+ getTreeFlags
4402
+ ) {
4403
+ const kustDir = dirname(kustAbs)
4404
+ const pathRefs = resourcePathRefsFromKustomizationObject(kustObj)
4405
+ const baseDirs = await k8sBaseDirsFromKustomizeResourcePathRefs(kustDir, pathRefs, root)
4406
+ if (baseDirs.length === 0) {
4407
+ return
4408
+ }
4409
+
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
+ })()
4419
+ for (const ref of pathRefs) {
4420
+ 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
+ }
4447
+ }
4448
+ }
4449
+ }
4450
+
4451
+ /**
4452
+ * Перевіряє всі кастомізації: (1) у k8s/base дереві HPA/PDB тільки з Deployment; (2) overlay, що
4453
+ * посилається на base, не додає HPA/PDB без Deployment у base.
4454
+ * @param {string} root корінь репо
4455
+ * @param {string[]} yamlFilesAbs yaml у k8s
4456
+ * @param {(msg: string) => void} fail callback
4457
+ * @param {(msg: string) => void} passFn pass
4458
+ * @returns {Promise<void>}
4459
+ */
4460
+ async function validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFilesAbs, fail, passFn) {
4461
+ const rootNorm = resolve(root)
4462
+ /** @type {Map<string, Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>>} */
4463
+ const treeFlagsMemo = new Map()
4464
+ /**
4465
+ * @param {string} kustPath абсолютний шлях до kustomization.yaml
4466
+ * @returns {Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} прапорці наявності ресурсів у дереві
4467
+ */
4468
+ const getTreeFlags = kustPath => {
4469
+ const k = resolve(kustPath)
4470
+ let p = treeFlagsMemo.get(k)
4471
+ if (p === undefined) {
4472
+ p = kustomizeResourceTreeHpaPdbDeploymentFlags(k, rootNorm)
4473
+ treeFlagsMemo.set(k, p)
4474
+ }
4475
+ return p
4476
+ }
4477
+ const kustFiles = yamlFilesAbs.filter(abs => basename(abs).toLowerCase() === 'kustomization.yaml')
4478
+ for (const kustAbs of kustFiles) {
4479
+ const rel = (relative(rootNorm, kustAbs) || kustAbs).replaceAll('\\', '/')
4480
+ const kust = await readFirstYamlObject(kustAbs)
4481
+ if (kust !== null) {
4482
+ if (isK8sBaseKustomizationRelPath(rel)) {
4483
+ await verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, passFn, getTreeFlags)
4484
+ } else {
4485
+ await verifyOverlayHpaPdbFileRefsRespectBaseDeployment(
4486
+ rootNorm,
4487
+ kustAbs,
4488
+ rel,
4489
+ kust,
4490
+ fail,
4491
+ passFn,
4492
+ getTreeFlags
4493
+ )
4494
+ }
4495
+ }
4496
+ }
4497
+ }
4498
+
4110
4499
  /**
4111
4500
  * Перевіряє прод-оверрайди HPA/PDB в одному kustomization.yaml.
4112
4501
  * @param {Record<string, unknown>} kust об'єкт kustomization
@@ -4376,8 +4765,12 @@ export async function check() {
4376
4765
 
4377
4766
  await validateKustomizationJson6902NoRemoveAddSamePath(root, yamlFiles, fail)
4378
4767
 
4768
+ await validateKustomizationPathRefsExistOnDisk(root, yamlFiles, fail)
4769
+
4379
4770
  await validateKustomizationPatchTargetsResolved(root, yamlFiles, fail)
4380
4771
 
4772
+ await validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFiles, fail, pass)
4773
+
4381
4774
  await ensureBaseKustomizationHasNamespace(root, yamlFiles, fail)
4382
4775
 
4383
4776
  await validateConfigMapNameMatchesDeployment(root, yamlFiles, fail, pass)
@@ -0,0 +1,156 @@
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
+ /**
15
+ * @param {string} t — токен образу в лапках або без
16
+ * @returns {string} токен без зовнішніх лапок
17
+ */
18
+ function stripFromImageQuotes(t) {
19
+ if (t.length >= 2 && (t[0] === '"' || t[0] === "'")) {
20
+ return t.slice(1, -1)
21
+ }
22
+ return t
23
+ }
24
+
25
+ /**
26
+ * Виділяє токен образу з рядка `FROM` (після зняття inline-коментаря, без AS).
27
+ * Підтримує прапорець `--platform=…` і форму `--platform` + значення.
28
+ *
29
+ * @param {string} line — рядок Dockerfile
30
+ * @returns {string | null} токен образу або null, якщо рядок не `FROM`
31
+ */
32
+ export function getFromImageToken(line) {
33
+ const withoutComment = line.split('#')[0].trim()
34
+ if (!withoutComment) return null
35
+ const m = withoutComment.match(/^\s*FROM\s+(.+)$/i)
36
+ if (!m) return null
37
+ const raw = m[1].trim()
38
+ const tokenRe = /(?:[^\s"]+|"[^"]*")+/g
39
+ const tokens = raw.match(tokenRe) || []
40
+ let i = 0
41
+ while (i < tokens.length) {
42
+ const t = tokens[i]
43
+ if (t === '--platform' || t.startsWith('--platform=')) {
44
+ if (t.startsWith('--platform=')) {
45
+ i += 1
46
+ } else if (tokens[i + 1] === undefined) {
47
+ i += 1
48
+ } else {
49
+ i += 2
50
+ }
51
+ } else if (t === '--' || t.toUpperCase() === 'AS') {
52
+ break
53
+ } else if (t.startsWith('--') && t.includes('=')) {
54
+ i += 1
55
+ } else if (t.startsWith('--')) {
56
+ i += 1
57
+ } else {
58
+ return stripFromImageQuotes(t)
59
+ }
60
+ }
61
+ return null
62
+ }
63
+
64
+ /**
65
+ * Схоже на звернення до Docker Hub (коротке ім’я, `docker.io/…`, не mirror.gcr.io).
66
+ * Не вважати Hub: явний чужий реєстр (`gcr.io/…`, `reg.example.com:5000/…`).
67
+ *
68
+ * @param {string} imageToken — ref образу (FROM)
69
+ * @returns {boolean} true, якщо схоже на pull з Docker Hub
70
+ */
71
+ export function isDockerHubStyleImageRef(imageToken) {
72
+ if (!imageToken) return false
73
+ if (/^mirror\.gcr\.io\//i.test(imageToken)) return false
74
+ const noDigest = imageToken.split('@')[0] || ''
75
+ if (!noDigest.includes('/')) {
76
+ return true
77
+ }
78
+ const first = noDigest.split('/')[0] || ''
79
+ if (first === 'docker.io' || first === 'index.docker.io') return true
80
+ if (first.includes('.')) return false
81
+ if (first === 'localhost' || /^\d+\.\d+/.test(first)) return false
82
+ if (first.includes(':') && /^\S+:\d+$/.test(first)) {
83
+ return false
84
+ }
85
+ return true
86
+ }
87
+
88
+ /**
89
+ * Нормалізує шлях репозиторію (без тега/digest) для порівняння: `library/node`, `oven/bun`, …
90
+ *
91
+ * @param {string} imageToken — ref образу
92
+ * @returns {string} нормалізований шлях репозиторію без тега
93
+ */
94
+ export function normalizeHubRepoPath(imageToken) {
95
+ let s = (imageToken.split('@')[0] || '').toLowerCase()
96
+ s = s.replace(/^(docker\.io|index\.docker\.io)\//, '')
97
+ if (!s.includes('/')) {
98
+ return `library/${s.split(':')[0]}`
99
+ }
100
+ const lastSl = s.lastIndexOf('/')
101
+ const lastCol = s.lastIndexOf(':')
102
+ if (lastCol > lastSl) {
103
+ s = s.slice(0, lastCol)
104
+ }
105
+ return s
106
+ }
107
+
108
+ const HUB_REPOS_REQUIRING_MIRROR = /** @type {const} */ ([
109
+ 'oven/bun',
110
+ 'library/alpine',
111
+ 'library/nginx',
112
+ 'library/node'
113
+ ])
114
+
115
+ const EXPECTED_MIRROR = /** @type {const} */ ({
116
+ 'oven/bun': 'mirror.gcr.io/oven/bun',
117
+ 'library/alpine': 'mirror.gcr.io/library/alpine',
118
+ 'library/nginx': 'mirror.gcr.io/library/nginx',
119
+ 'library/node': 'mirror.gcr.io/library/node'
120
+ })
121
+
122
+ /**
123
+ * Якщо образ тягнеть з Hub і підлягає дзеркалу — повертає рекомендовану заміну, інакше `null`.
124
+ *
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\.io\//i.test(imageToken)) return null
131
+ if (!isDockerHubStyleImageRef(imageToken)) return null
132
+ const norm = normalizeHubRepoPath(imageToken)
133
+ if (!HUB_REPOS_REQUIRING_MIRROR.includes(/** @type {any} */ (norm))) {
134
+ return null
135
+ }
136
+ return EXPECTED_MIRROR[/** @type {keyof typeof EXPECTED_MIRROR} */ (norm)]
137
+ }
138
+
139
+ /**
140
+ * Сканує вміст Dockerfile / Containerfile — повертає рядок помилки або `null`.
141
+ *
142
+ * @param {string} fileContent — повний вміст Dockerfile
143
+ * @returns {string | null} повідомлення з номером рядка або null
144
+ */
145
+ 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]
149
+ const image = getFromImageToken(line)
150
+ const expected = getRequiredMirrorGcrImage(image)
151
+ if (expected) {
152
+ return `рядок ${n + 1}: FROM має тягнути ${expected} (замість ${image})`
153
+ }
154
+ }
155
+ return null
156
+ }
@@ -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