@nitra/cursor 1.8.125 → 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
@@ -490,7 +490,7 @@ patches:
490
490
  # yaml-language-server: $schema=https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/secrets.infisical.com/infisicalsecret_v1alpha1.json
491
491
  ```
492
492
 
493
- **Приклад (Gateway API):** `apiVersion: gateway.networking.k8s.io/v1beta1`, `kind: HTTPRoute`:
493
+ **Приклад (Gateway API):** `apiVersion: gateway.networking.k8s.io/v1`, `kind: HTTPRoute`:
494
494
 
495
495
  ```yaml
496
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.125",
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)
@@ -402,7 +402,7 @@ function pathsFromKustomizationObject(obj) {
402
402
  * як у `pathsFromKustomizationObject`, плюс **`patchesJson6902[].path`**, плюс **`configurations[]`**
403
403
  * (рядки-шляхи) і **`replacements[].path`**, якщо задано.
404
404
  * @param {unknown} obj корінь першого документа
405
- * @returns {string[]}
405
+ * @returns {string[]} масив локальних шляхів для перевірки існування на диску
406
406
  */
407
407
  export function kustomizePathRefsForExistenceCheck(obj) {
408
408
  if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
@@ -463,47 +463,44 @@ async function validateOneKustomizationPathRefsExist(root, kustAbs, rootNorm, fa
463
463
  const refs = kustomizePathRefsForExistenceCheck(kust)
464
464
  const kustDir = dirname(resolve(kustAbs))
465
465
  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)) {
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 {
492
490
  fail(
493
- `${rel}: «${r}» за правилами k8s у kustomization для файлів дозволені лише розширення .yaml / .yml (k8s.mdc)`
491
+ `${rel}: посилання «${r}» виходить за межі репозиторію (resolve: ${(relative(rootNorm, target) || target).replaceAll('\\', '/')
492
+ }) (k8s.mdc)`
494
493
  )
495
494
  }
496
- } else if (!st.isDirectory()) {
497
- fail(`${rel}: «${r}» — ні файл, ні каталог (k8s.mdc)`)
498
495
  }
499
496
  }
500
497
  }
501
498
 
502
499
  /**
503
500
  * Усі `kustomization.yaml` під `k8s`: локальні `path` / ресурси мають існувати.
504
- * @param {string} root
505
- * @param {string[]} yamlFilesAbs
506
- * @param {(msg: string) => void} fail
501
+ * @param {string} root корінь репозиторію
502
+ * @param {string[]} yamlFilesAbs абсолютні шляхи YAML-файлів у k8s
503
+ * @param {(msg: string) => void} fail callback для повідомлень про помилки
507
504
  * @returns {Promise<void>}
508
505
  */
509
506
  async function validateKustomizationPathRefsExistOnDisk(root, yamlFilesAbs, fail) {
@@ -4293,26 +4290,26 @@ async function k8sBaseDirsFromKustomizeResourcePathRefs(kustDir, pathRefs, rootN
4293
4290
  /** @type {string[]} */
4294
4291
  const out = []
4295
4292
  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
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
+ }
4314
4312
  }
4315
- out.push(resolved)
4316
4313
  }
4317
4314
  return out
4318
4315
  }
@@ -4349,20 +4346,13 @@ async function yamlFileContainsHpaOrPdbDocument(fileAbs) {
4349
4346
  if (docs === undefined) {
4350
4347
  return false
4351
4348
  }
4352
- for (const doc of docs) {
4353
- if (doc.errors.length > 0) {
4354
- continue
4355
- }
4349
+ return docs.some(doc => {
4350
+ if (doc.errors.length > 0) return false
4356
4351
  const o = doc.toJSON()
4357
- if (o === null || typeof o !== 'object' || Array.isArray(o)) {
4358
- continue
4359
- }
4352
+ if (o === null || typeof o !== 'object' || Array.isArray(o)) return false
4360
4353
  const k = /** @type {Record<string, unknown>} */ (o).kind
4361
- if (k === 'HorizontalPodAutoscaler' || k === 'PodDisruptionBudget') {
4362
- return true
4363
- }
4364
- }
4365
- return false
4354
+ return k === 'HorizontalPodAutoscaler' || k === 'PodDisruptionBudget'
4355
+ })
4366
4356
  }
4367
4357
 
4368
4358
  /**
@@ -4398,7 +4388,7 @@ async function verifyK8sBaseKustomizeHpaPdbNeedDeployment(kustAbs, rel, fail, pa
4398
4388
  * @param {Record<string, unknown>} kustObj перший документ
4399
4389
  * @param {(msg: string) => void} fail callback
4400
4390
  * @param {(msg: string) => void} passFn success
4401
- * @param {(kust: string) => Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} getTreeFlags
4391
+ * @param {(kust: string) => Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} getTreeFlags функція отримання прапорців дерева kustomize
4402
4392
  * @returns {Promise<void>}
4403
4393
  */
4404
4394
  async function verifyOverlayHpaPdbFileRefsRespectBaseDeployment(
@@ -4427,41 +4417,33 @@ async function verifyOverlayHpaPdbFileRefsRespectBaseDeployment(
4427
4417
  return false
4428
4418
  })()
4429
4419
  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
- )
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
+ }
4465
4447
  }
4466
4448
  }
4467
4449
  }
@@ -4480,8 +4462,8 @@ async function validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFilesAbs,
4480
4462
  /** @type {Map<string, Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>>} */
4481
4463
  const treeFlagsMemo = new Map()
4482
4464
  /**
4483
- * @param {string} kustPath
4484
- * @returns {Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>}
4465
+ * @param {string} kustPath абсолютний шлях до kustomization.yaml
4466
+ * @returns {Promise<{ hasDeployment: boolean, hasHpa: boolean, hasPdb: boolean }>} прапорці наявності ресурсів у дереві
4485
4467
  */
4486
4468
  const getTreeFlags = kustPath => {
4487
4469
  const k = resolve(kustPath)
@@ -4496,21 +4478,20 @@ async function validateKustomizeHpaPdbOnlyWithBaseDeployment(root, yamlFilesAbs,
4496
4478
  for (const kustAbs of kustFiles) {
4497
4479
  const rel = (relative(rootNorm, kustAbs) || kustAbs).replaceAll('\\', '/')
4498
4480
  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
- )
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
+ }
4514
4495
  }
4515
4496
  }
4516
4497
  }
@@ -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