@nitra/cursor 1.8.69 → 1.8.73

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
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Правила для проєктів AbInBev Efes
3
3
  alwaysApply: true
4
- version: '1.2'
4
+ version: '1.4'
5
5
  ---
6
6
 
7
7
  Правило **abie** для споживачів **@nitra/cursor**: **k8s** (**Deployment** + **HealthCheckPolicy**), overlay **ua** / **ru** (**nodeSelector**, видалення **HealthCheckPolicy** у **ru**) і гілки в **clean-merged-branch**. **`npx @nitra/cursor check abie`** виконується лише якщо в **`.n-cursor.json`** у **`rules`** є **`abie`** — інакше вихід **0** без зауважень.
@@ -30,6 +30,49 @@ spec:
30
30
  name: СЕРВІС
31
31
  ```
32
32
 
33
+ ## k8s: overlay **HTTPRoute** `nginx-run` (**ua** / **ru**)
34
+
35
+ Якщо під **`k8s`** є **Deployment**, у **кожному** **`ua/kustomization.yaml`** та **`ru/kustomization.yaml`** має бути inline **JSON6902** у **`patches[].patch`** на **`target.kind: HTTPRoute`**, **`name: nginx-run`**: **replace** **`/spec/hostnames`** (допустимі домени — як у прикладах нижче) і **replace** **`/spec/parentRefs/0/namespace`** (**`ua`** або **`ru`**). Для **ru** додатково потрібна анотація **`gwin.yandex.cloud/rules.http.upgradeTypes: websocket`** (зазвичай **op: add**, **`/metadata/annotations`**). Критерії — **`validateAbieNginxRunHttpRoutePatches`** / **`getCombinedNginxRunPatchTextFromKustomization`** у **`npm/scripts/check-abie.mjs`**.
36
+
37
+ ```yaml title="…/ua/kustomization.yaml (фрагмент)"
38
+ - target:
39
+ kind: HTTPRoute
40
+ name: nginx-run
41
+ patch: |-
42
+ - op: replace
43
+ path: /spec/hostnames
44
+ value:
45
+ - "abie.app" # також допускається vybeerai.com.ua, *.vybeerai.com.ua, *.abie.app
46
+ - op: replace
47
+ path: /spec/parentRefs/0/namespace
48
+ value: ua
49
+ ```
50
+
51
+ ```yaml title="…/ru/kustomization.yaml (фрагмент)"
52
+ - target:
53
+ kind: HTTPRoute
54
+ name: nginx-run
55
+ patch: |-
56
+ - op: replace
57
+ path: /spec/hostnames
58
+ value:
59
+ - "napitkivmeste.tech" # також допускається выбирайонлайн.рф, *.napitkivmeste.tech, *.выбирайонлайн.рф
60
+ - op: replace
61
+ path: /spec/parentRefs/0/namespace
62
+ value: ru
63
+ ```
64
+
65
+ ```yaml title="…/ru/kustomization.yaml (фрагмент)"
66
+ - target:
67
+ kind: HTTPRoute
68
+ name: nginx-run
69
+ patch: |-
70
+ - op: add
71
+ path: /metadata/annotations
72
+ value:
73
+ gwin.yandex.cloud/rules.http.upgradeTypes: "websocket"
74
+ ```
75
+
33
76
  ## k8s: overlay **`ru`** і HealthCheckPolicy
34
77
 
35
78
  Якщо в дереві **k8s** є **HealthCheckPolicy**, **check abie** вимагає **`ru/kustomization.yaml`** з patch **`$patch: delete`** для політики (узгоджено з **k8s.mdc** / **check-k8s**, **`ruKustomizationHasHealthCheckDeletePatch`** у **`npm/scripts/check-k8s.mjs`**). Підстав реальне ім’я замість **`СЕРВІС`**:
@@ -74,6 +117,18 @@ patches:
74
117
  yandex.cloud/preemptible: "false"
75
118
  ```
76
119
 
120
+ ### Базовий Deployment (`…/base/`)
121
+
122
+ Якщо **Deployment** у YAML під **`k8s`** лежить у шляху з сегментом **`base`** (наприклад **`…/k8s/base/deploy.yaml`**), у **`spec.template.spec.nodeSelector`** має бути **`preem`** зі значенням **істинно** (**`true`** або рядок **`'true'`**) — overlay **ua** / **ru** підміняє селектор через kustomize. Деталі — **`deploymentDocumentHasAbieBasePreemNodeSelector`** / **`isAbieK8sBaseYamlPath`** у **`npm/scripts/check-abie.mjs`**.
123
+
124
+ ```yaml title="…/base/deploy.yaml (фрагмент)"
125
+ spec:
126
+ template:
127
+ spec:
128
+ nodeSelector:
129
+ preem: 'true' # буде замінено через kustomize
130
+ ```
131
+
77
132
  ## Git branches
78
133
 
79
134
  У **`.github/workflows/clean-merged-branch.yml`** у кроці **`phpdocker-io/github-actions-delete-abandoned-branches`** значення **`with.ignore_branches`** має містити **dev**, **ua** та **ru** (разом з іншими гілками, якщо потрібно), наприклад:
package/mdc/k8s.mdc CHANGED
@@ -206,6 +206,30 @@ patches:
206
206
  value: dev
207
207
  ```
208
208
 
209
+ 4. Якщо в kustomization.yaml є remove разом з add на однаковий path:
210
+
211
+ ```yaml title="overlay/kustomization.yaml (фрагмент)"
212
+ - op: remove
213
+ path: /spec/template/spec/nodeSelector
214
+ - op: add
215
+ path: /spec/template/spec/nodeSelector
216
+ value:
217
+ preem: "false"
218
+ ```
219
+
220
+ заміняй на replace:
221
+
222
+ ```yaml title="overlay/kustomization.yaml (фрагмент)"
223
+ - target:
224
+ kind: Deployment
225
+ name: x
226
+ patch: |-
227
+ - op: replace
228
+ path: /spec/template/spec/nodeSelector
229
+ value:
230
+ preem: "false"
231
+ ```
232
+
209
233
  ## Перевірка
210
234
 
211
235
  **`npx @nitra/cursor check k8s`** — програмні критерії в **JSDoc на початку** **`npm/scripts/check-k8s.mjs`**. Якщо під **`k8s`** немає **`*.yaml`** — крок пропущено. Канон **`$schema`** для редактора — розділ **«Визначення схеми YAML`** нижче.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nitra/cursor",
3
- "version": "1.8.69",
3
+ "version": "1.8.73",
4
4
  "description": "CLI для завантаження cursor-правил (префікс n-) у локальний репозиторій",
5
5
  "keywords": [
6
6
  "cli",
@@ -14,9 +14,16 @@
14
14
  * Якщо в дереві **k8s** є **HealthCheckPolicy**, перевіряється **`ru/kustomization.yaml`** з patch **`$patch: delete`**
15
15
  * (логіка вмісту — **`ruKustomizationHasHealthCheckDeletePatch`** у **check-k8s.mjs**, узгоджено з **k8s.mdc**).
16
16
  *
17
- * **nodeSelector:** якщо є **Deployment** під **k8s**, у кожному **`ua/kustomization.yaml`** та **`ru/kustomization.yaml`**
17
+ * **nodeSelector (base):** якщо **Deployment** лежить у шляху з сегментом **`base`** (наприклад **`…/k8s/base/deploy.yaml`**),
18
+ * у **`spec.template.spec.nodeSelector`** має бути **`preem`** з булевим значенням **true** або рядком **`'true'`** — overlay **ua** та **ru** далі підміняють селектор.
19
+ *
20
+ * **nodeSelector (overlay):** якщо є **Deployment** під **k8s**, у кожному **`ua/kustomization.yaml`** та **`ru/kustomization.yaml`**
18
21
  * має бути inline **JSON6902** patch на **`kind: Deployment`**: для **ua** — **`op: add`**, **`path: /spec/template/spec/nodeSelector`**,
19
22
  * **`preem: false`**; для **ru** — **`op: replace`**, той самий **path**, **`yandex.cloud/preemptible: false`** (див. abie.mdc).
23
+ *
24
+ * **HTTPRoute nginx-run:** за тієї ж умови (**Deployment** під **k8s**) у **кожному** **`ua`/`ru` kustomization** має бути
25
+ * inline **JSON6902** на **`kind: HTTPRoute`**, **`name: nginx-run`**: **replace** **`/spec/hostnames`** (домени з abie.mdc),
26
+ * **replace** **`/spec/parentRefs/0/namespace`** (**ua** / **ru**); для **ru** також **`gwin.yandex.cloud/rules.http.upgradeTypes: websocket`**.
20
27
  */
21
28
  import { existsSync } from 'node:fs'
22
29
  import { readFile } from 'node:fs/promises'
@@ -25,7 +32,7 @@ import { dirname, join, relative } from 'node:path'
25
32
  import { parseAllDocuments } from 'yaml'
26
33
 
27
34
  import { pathHasK8sSegment, ruKustomizationHasHealthCheckDeletePatch } from './check-k8s.mjs'
28
- import { pass } from './utils/pass.mjs'
35
+ import { createCheckReporter } from './utils/check-reporter.mjs'
29
36
  import { flattenWorkflowSteps, getStepUses, parseWorkflowYaml } from './utils/gha-workflow.mjs'
30
37
  import { walkDir } from './utils/walkDir.mjs'
31
38
 
@@ -59,6 +66,60 @@ export function isUaKustomizationPath(rel) {
59
66
  return /(^|\/)ua\/kustomization\.yaml$/u.test(norm)
60
67
  }
61
68
 
69
+ /**
70
+ * Чи відносний шлях до YAML під **k8s** вказує на файл у каталозі **`base`** (сегмент **`base`** у шляху), abie.mdc.
71
+ * @param {string} rel шлях від кореня репозиторію
72
+ * @returns {boolean} true, якщо в шляху є **`/base/`**
73
+ */
74
+ export function isAbieK8sBaseYamlPath(rel) {
75
+ const norm = rel.replaceAll('\\', '/')
76
+ return /(^|\/)base\//u.test(norm)
77
+ }
78
+
79
+ /**
80
+ * Чи значення **`preem`** у base **Deployment** вважається «істинним» за abie.mdc (**true** або рядок **`true`** без урахування регістру).
81
+ * @param {unknown} v значення з YAML
82
+ * @returns {boolean} **true**, якщо значення вважається істинним за abie.mdc
83
+ */
84
+ function isAbiePreemTruthy(v) {
85
+ if (v === true) {
86
+ return true
87
+ }
88
+ if (typeof v === 'string' && v.trim().toLowerCase() === 'true') {
89
+ return true
90
+ }
91
+ return false
92
+ }
93
+
94
+ /**
95
+ * Чи документ **Deployment** у **`…/base/…`** містить **`spec.template.spec.nodeSelector.preem`** зі значенням **true** (abie.mdc).
96
+ * @param {unknown} obj корінь YAML-документа (**Deployment**)
97
+ * @returns {boolean} true, якщо критерії виконано
98
+ */
99
+ export function deploymentDocumentHasAbieBasePreemNodeSelector(obj) {
100
+ if (!isDeploymentDoc(obj)) {
101
+ return false
102
+ }
103
+ const rec = /** @type {Record<string, unknown>} */ (obj)
104
+ const spec = rec.spec
105
+ if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
106
+ return false
107
+ }
108
+ const template = /** @type {Record<string, unknown>} */ (spec).template
109
+ if (template === null || typeof template !== 'object' || Array.isArray(template)) {
110
+ return false
111
+ }
112
+ const podSpec = /** @type {Record<string, unknown>} */ (template).spec
113
+ if (podSpec === null || typeof podSpec !== 'object' || Array.isArray(podSpec)) {
114
+ return false
115
+ }
116
+ const nodeSelector = /** @type {Record<string, unknown>} */ (podSpec).nodeSelector
117
+ if (nodeSelector === null || typeof nodeSelector !== 'object' || Array.isArray(nodeSelector)) {
118
+ return false
119
+ }
120
+ return isAbiePreemTruthy(nodeSelector.preem)
121
+ }
122
+
62
123
  /**
63
124
  * Чи увімкнено правило **abie** у конфігу репозиторію.
64
125
  * @param {string} root корінь репозиторію (cwd)
@@ -213,6 +274,65 @@ async function collectDeploymentDirs(root, yamlAbs, fail) {
213
274
  return dirs
214
275
  }
215
276
 
277
+ /**
278
+ * Для кожного **Deployment** у YAML під **`k8s`** з шляхом **`…/base/…`** вимагає **`spec.template.spec.nodeSelector.preem: true`** (abie.mdc).
279
+ * @param {string} root корінь репозиторію
280
+ * @param {string[]} yamlFilesAbs yaml під k8s
281
+ * @param {(msg: string) => void} fail callback
282
+ * @param {(msg: string) => void} passFn успішне повідомлення
283
+ * @returns {Promise<void>}
284
+ */
285
+ async function ensureAbieBaseDeploymentPreemNodeSelector(root, yamlFilesAbs, fail, passFn) {
286
+ const baseFiles = yamlFilesAbs.filter(abs => {
287
+ const rel = relative(root, abs).replaceAll('\\', '/') || abs
288
+ return isAbieK8sBaseYamlPath(rel)
289
+ })
290
+ let anyBaseDeployment = false
291
+ for (const abs of baseFiles) {
292
+ const rel = relative(root, abs).replaceAll('\\', '/') || abs
293
+ let raw
294
+ try {
295
+ raw = await readFile(abs, 'utf8')
296
+ } catch (error) {
297
+ const msg = error instanceof Error ? error.message : String(error)
298
+ fail(`${rel}: не вдалося прочитати (${msg})`)
299
+ return
300
+ }
301
+ const body = stripBom(raw)
302
+ const lines = body.split(/\r?\n/u)
303
+ const first = lines[0] ?? ''
304
+ const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
305
+ /** @type {import('yaml').Document[]} */
306
+ let docs
307
+ try {
308
+ docs = parseAllDocuments(rest)
309
+ } catch (error) {
310
+ const msg = error instanceof Error ? error.message : String(error)
311
+ fail(`${rel}: YAML (${msg})`)
312
+ return
313
+ }
314
+ for (const doc of docs) {
315
+ if (doc.errors.length === 0) {
316
+ const obj = doc.toJSON()
317
+ if (isDeploymentDoc(obj)) {
318
+ anyBaseDeployment = true
319
+ if (!deploymentDocumentHasAbieBasePreemNodeSelector(obj)) {
320
+ fail(
321
+ `${rel}: Deployment у base: потрібен spec.template.spec.nodeSelector.preem: true (або 'true') — abie.mdc`
322
+ )
323
+ return
324
+ }
325
+ }
326
+ }
327
+ }
328
+ }
329
+ if (anyBaseDeployment) {
330
+ passFn('Deployment у …/base/…: nodeSelector.preem відповідає abie.mdc')
331
+ } else {
332
+ passFn('Немає Deployment у шляхах …/base/… — перевірку preem у base пропущено')
333
+ }
334
+ }
335
+
216
336
  /**
217
337
  * Прибирає BOM на початку файлу.
218
338
  * @param {string} s вміст
@@ -352,6 +472,125 @@ export function kustomizationHasAbieDeploymentNodeSelectorPatch(raw, mode) {
352
472
  return false
353
473
  }
354
474
 
475
+ /** Домени **hostnames** для overlay **ua** (підрядки у JSON6902-тексті patch), abie.mdc. */
476
+ const ABIE_UA_HTTPROUTE_HOST_MARKERS = ['abie.app', 'vybeerai.com.ua', '*.abie.app', '*.vybeerai.com.ua']
477
+
478
+ /** Домени **hostnames** для overlay **ru** (підрядки), abie.mdc. */
479
+ const ABIE_RU_HTTPROUTE_HOST_MARKERS = [
480
+ 'napitkivmeste.tech',
481
+ 'выбирайонлайн.рф',
482
+ '*.napitkivmeste.tech',
483
+ '*.выбирайонлайн.рф'
484
+ ]
485
+
486
+ /**
487
+ * Збирає тексти inline **patch** для **HTTPRoute/nginx-run** з одного розібраного документа **Kustomization**.
488
+ * @param {import('yaml').Document} doc документ після **parseAllDocuments**
489
+ * @returns {string[]} непорожні рядки **patch**
490
+ */
491
+ function collectNginxRunPatchStringsFromKustomizationDoc(doc) {
492
+ if (doc.errors.length > 0) {
493
+ return []
494
+ }
495
+ const root = doc.toJSON()
496
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) {
497
+ return []
498
+ }
499
+ const rec = /** @type {Record<string, unknown>} */ (root)
500
+ if (rec.kind !== 'Kustomization') {
501
+ return []
502
+ }
503
+ const patches = rec.patches
504
+ if (!Array.isArray(patches)) {
505
+ return []
506
+ }
507
+ /** @type {string[]} */
508
+ const out = []
509
+ for (const p of patches) {
510
+ if (p !== null && typeof p === 'object' && !Array.isArray(p)) {
511
+ const pr = /** @type {Record<string, unknown>} */ (p)
512
+ const target = pr.target
513
+ if (target !== null && typeof target === 'object' && !Array.isArray(target)) {
514
+ const tg = /** @type {Record<string, unknown>} */ (target)
515
+ if (tg.kind === 'HTTPRoute' && tg.name === 'nginx-run') {
516
+ const patchStr = pr.patch
517
+ if (typeof patchStr === 'string' && patchStr.trim() !== '') {
518
+ out.push(patchStr)
519
+ }
520
+ }
521
+ }
522
+ }
523
+ }
524
+ return out
525
+ }
526
+
527
+ /**
528
+ * Збирає всі inline **JSON6902**-фрагменти для **HTTPRoute/nginx-run** у **kustomization.yaml** (усі документи у файлі).
529
+ * @param {string} raw повний текст файлу
530
+ * @returns {string} текст для **`validateAbieNginxRunHttpRoutePatches`** (може бути порожнім)
531
+ */
532
+ export function getCombinedNginxRunPatchTextFromKustomization(raw) {
533
+ const body = stripBom(raw)
534
+ const lines = body.split(/\r?\n/u)
535
+ const first = lines[0] ?? ''
536
+ const rest = MODELINE_RE.test(first.trim()) ? lines.slice(1).join('\n') : body
537
+ /** @type {import('yaml').Document[]} */
538
+ let docs
539
+ try {
540
+ docs = parseAllDocuments(rest)
541
+ } catch {
542
+ return ''
543
+ }
544
+ /** @type {string[]} */
545
+ const chunks = []
546
+ for (const doc of docs) {
547
+ chunks.push(...collectNginxRunPatchStringsFromKustomizationDoc(doc))
548
+ }
549
+ return chunks.join('\n')
550
+ }
551
+
552
+ /**
553
+ * Перевіряє сукупний текст patch(ів) **HTTPRoute/nginx-run** на відповідність abie.mdc.
554
+ * @param {string} combined текст одного або кількох inline **patch**, розділених символом нового рядка
555
+ * @param {'ua' | 'ru'} mode **ua** або **ru**
556
+ * @returns {string | null} повідомлення про помилку або **null**
557
+ */
558
+ export function validateAbieNginxRunHttpRoutePatches(combined, mode) {
559
+ if (typeof combined !== 'string' || combined.trim() === '') {
560
+ return `очікується patch target kind HTTPRoute name nginx-run (replace hostnames, parentRefs namespace ${mode}; для ru — також gwin… upgradeTypes websocket) — abie.mdc`
561
+ }
562
+ const hasHostnamesReplace = /-\s*op:\s*replace\b[\s\S]{0,200}?path:\s*\/spec\/hostnames\b/m.test(combined)
563
+ if (!hasHostnamesReplace) {
564
+ return 'HTTPRoute nginx-run: потрібен блок op replace з path /spec/hostnames (abie.mdc)'
565
+ }
566
+ const markers = mode === 'ua' ? ABIE_UA_HTTPROUTE_HOST_MARKERS : ABIE_RU_HTTPROUTE_HOST_MARKERS
567
+ if (!markers.some(m => combined.includes(m))) {
568
+ return `HTTPRoute nginx-run: у value для /spec/hostnames має бути один із доменів abie (${markers.join(', ')}) — abie.mdc`
569
+ }
570
+ const namespaceOk =
571
+ mode === 'ua'
572
+ ? /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ua['"]?(?:\s|$)/mu.test(combined)
573
+ : /path:\s*\/spec\/parentRefs\/0\/namespace\b[\s\S]{0,200}?value:\s*['"]?ru['"]?(?:\s|$)/mu.test(combined)
574
+ if (!namespaceOk) {
575
+ return `HTTPRoute nginx-run: потрібен replace path /spec/parentRefs/0/namespace з value ${mode} (abie.mdc)`
576
+ }
577
+ if (mode === 'ru' && !/gwin\.yandex\.cloud\/rules\.http\.upgradeTypes:\s*['"]?websocket['"]?/m.test(combined)) {
578
+ return 'HTTPRoute nginx-run (ru): потрібна анотація gwin.yandex.cloud/rules.http.upgradeTypes: websocket (abie.mdc)'
579
+ }
580
+ return null
581
+ }
582
+
583
+ /**
584
+ * Чи **kustomization** містить валідні для abie записи **patch** для **HTTPRoute/nginx-run** (**ua** або **ru**).
585
+ * @param {string} raw повний текст **kustomization.yaml**
586
+ * @param {'ua' | 'ru'} mode overlay
587
+ * @returns {boolean} true, якщо **`validateAbieNginxRunHttpRoutePatches`** повертає **null**
588
+ */
589
+ export function kustomizationHasAbieNginxRunHttpRoutePatch(raw, mode) {
590
+ const combined = getCombinedNginxRunPatchTextFromKustomization(raw)
591
+ return validateAbieNginxRunHttpRoutePatches(combined, mode) === null
592
+ }
593
+
355
594
  /**
356
595
  * Перевіряє **hc.yaml** на відповідність abie.mdc.
357
596
  * @param {string} raw повний текст файлу
@@ -536,9 +775,10 @@ async function ensureRuKustomizationHealthCheckDelete(root, yamlFilesAbs, health
536
775
  * @param {string} root корінь репозиторію
537
776
  * @param {string[]} yamlFilesAbs yaml під k8s
538
777
  * @param {(msg: string) => void} fail callback
778
+ * @param {(msg: string) => void} passFn успішне повідомлення
539
779
  * @returns {Promise<void>}
540
780
  */
541
- async function ensureUaRuAbieNodeSelectorPatches(root, yamlFilesAbs, fail) {
781
+ async function ensureUaRuAbieNodeSelectorPatches(root, yamlFilesAbs, fail, passFn) {
542
782
  const uaAbsList = yamlFilesAbs.filter(abs => isUaKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
543
783
  if (uaAbsList.length === 0) {
544
784
  fail(
@@ -562,7 +802,7 @@ async function ensureUaRuAbieNodeSelectorPatches(root, yamlFilesAbs, fail) {
562
802
  )
563
803
  return
564
804
  }
565
- pass(`${rel}: nodeSelector patch (ua) відповідає abie.mdc`)
805
+ passFn(`${rel}: nodeSelector patch (ua) відповідає abie.mdc`)
566
806
  }
567
807
 
568
808
  const ruAbsList = yamlFilesAbs.filter(abs => isRuKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
@@ -588,7 +828,69 @@ async function ensureUaRuAbieNodeSelectorPatches(root, yamlFilesAbs, fail) {
588
828
  )
589
829
  return
590
830
  }
591
- pass(`${rel}: nodeSelector patch (ru) відповідає abie.mdc`)
831
+ passFn(`${rel}: nodeSelector patch (ru) відповідає abie.mdc`)
832
+ }
833
+ }
834
+
835
+ /**
836
+ * Якщо є **Deployment** під **k8s**, вимагає в кожному overlay **ua** та **ru** patch **HTTPRoute/nginx-run** (abie.mdc).
837
+ * @param {string} root корінь репозиторію
838
+ * @param {string[]} yamlFilesAbs yaml під k8s
839
+ * @param {(msg: string) => void} fail callback
840
+ * @param {(msg: string) => void} passFn успішне повідомлення
841
+ * @returns {Promise<void>}
842
+ */
843
+ async function ensureUaRuAbieHttpRoutePatches(root, yamlFilesAbs, fail, passFn) {
844
+ const uaAbsList = yamlFilesAbs.filter(abs => isUaKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
845
+ if (uaAbsList.length === 0) {
846
+ fail(
847
+ 'Є Deployment у k8s — додай ua/kustomization.yaml з patch HTTPRoute nginx-run (hostnames, parentRefs namespace ua) — abie.mdc'
848
+ )
849
+ return
850
+ }
851
+ for (const abs of uaAbsList) {
852
+ const rel = relative(root, abs).replaceAll('\\', '/') || abs
853
+ let raw
854
+ try {
855
+ raw = await readFile(abs, 'utf8')
856
+ } catch (error) {
857
+ const msg = error instanceof Error ? error.message : String(error)
858
+ fail(`${rel}: не вдалося прочитати (${msg})`)
859
+ return
860
+ }
861
+ const combined = getCombinedNginxRunPatchTextFromKustomization(raw)
862
+ const v = validateAbieNginxRunHttpRoutePatches(combined, 'ua')
863
+ if (v !== null) {
864
+ fail(`${rel}: ${v}`)
865
+ return
866
+ }
867
+ passFn(`${rel}: HTTPRoute nginx-run (ua) відповідає abie.mdc`)
868
+ }
869
+
870
+ const ruAbsList = yamlFilesAbs.filter(abs => isRuKustomizationPath(relative(root, abs).replaceAll('\\', '/') || abs))
871
+ if (ruAbsList.length === 0) {
872
+ fail(
873
+ 'Є Deployment у k8s — додай ru/kustomization.yaml з patch HTTPRoute nginx-run (hostnames, namespace ru, gwin websocket) — abie.mdc'
874
+ )
875
+ return
876
+ }
877
+ for (const abs of ruAbsList) {
878
+ const rel = relative(root, abs).replaceAll('\\', '/') || abs
879
+ let raw
880
+ try {
881
+ raw = await readFile(abs, 'utf8')
882
+ } catch (error) {
883
+ const msg = error instanceof Error ? error.message : String(error)
884
+ fail(`${rel}: не вдалося прочитати (${msg})`)
885
+ return
886
+ }
887
+ const combined = getCombinedNginxRunPatchTextFromKustomization(raw)
888
+ const v = validateAbieNginxRunHttpRoutePatches(combined, 'ru')
889
+ if (v !== null) {
890
+ fail(`${rel}: ${v}`)
891
+ return
892
+ }
893
+ passFn(`${rel}: HTTPRoute nginx-run (ru) відповідає abie.mdc`)
592
894
  }
593
895
  }
594
896
 
@@ -597,17 +899,14 @@ async function ensureUaRuAbieNodeSelectorPatches(root, yamlFilesAbs, fail) {
597
899
  * @returns {Promise<number>} 0 — OK, 1 — є порушення
598
900
  */
599
901
  export async function check() {
600
- let exitCode = 0
601
- const fail = msg => {
602
- console.log(` ❌ ${msg}`)
603
- exitCode = 1
604
- }
902
+ const reporter = createCheckReporter()
903
+ const { pass, fail } = reporter
605
904
 
606
905
  const root = process.cwd()
607
906
  const enabled = await isAbieRuleEnabled(root)
608
907
  if (!enabled) {
609
908
  pass(`Правило abie не увімкнено в ${CONFIG_FILE} (rules) — перевірку пропущено`)
610
- return 0
909
+ return reporter.getExitCode()
611
910
  }
612
911
 
613
912
  pass('Правило abie увімкнено — виконуємо перевірки')
@@ -672,6 +971,8 @@ export async function check() {
672
971
  )
673
972
  }
674
973
  }
974
+ pass('Є Deployment — перевіряємо base: spec.template.spec.nodeSelector.preem (abie.mdc)')
975
+ await ensureAbieBaseDeploymentPreemNodeSelector(root, yamlFiles, fail, pass)
675
976
  } else {
676
977
  pass('Немає Deployment у дереві k8s — перевірку hc.yaml пропущено')
677
978
  }
@@ -681,8 +982,10 @@ export async function check() {
681
982
 
682
983
  if (deploymentDirs.size > 0) {
683
984
  pass('Є Deployment — перевіряємо nodeSelector у ua/ru kustomization (abie.mdc)')
684
- await ensureUaRuAbieNodeSelectorPatches(root, yamlFiles, fail)
985
+ await ensureUaRuAbieNodeSelectorPatches(root, yamlFiles, fail, pass)
986
+ pass('Є Deployment — перевіряємо HTTPRoute nginx-run у ua/ru kustomization (abie.mdc)')
987
+ await ensureUaRuAbieHttpRoutePatches(root, yamlFiles, fail, pass)
685
988
  }
686
989
 
687
- return exitCode
990
+ return reporter.getExitCode()
688
991
  }
@@ -17,7 +17,7 @@
17
17
  import { existsSync } from 'node:fs'
18
18
  import { readFile } from 'node:fs/promises'
19
19
 
20
- import { pass } from './utils/pass.mjs'
20
+ import { createCheckReporter } from './utils/check-reporter.mjs'
21
21
 
22
22
  /**
23
23
  * Чи ім'я пакета дозволене в кореневих `devDependencies` за bun.mdc (лише `@cspell/*` та `@nitra/*`).
@@ -53,11 +53,8 @@ async function loadNCursorRules() {
53
53
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
54
54
  */
55
55
  export async function check() {
56
- let exitCode = 0
57
- const fail = msg => {
58
- console.log(` ❌ ${msg}`)
59
- exitCode = 1
60
- }
56
+ const reporter = createCheckReporter()
57
+ const { pass, fail } = reporter
61
58
 
62
59
  const forbidden = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', '.yarnrc.yml']
63
60
  for (const f of forbidden) {
@@ -164,5 +161,5 @@ export async function check() {
164
161
  }
165
162
  }
166
163
 
167
- return exitCode
164
+ return reporter.getExitCode()
168
165
  }
@@ -8,7 +8,7 @@
8
8
  import { basename } from 'node:path'
9
9
 
10
10
  import { lintDockerfileWithHadolint, posixRel } from './utils/docker-hadolint.mjs'
11
- import { pass } from './utils/pass.mjs'
11
+ import { createCheckReporter } from './utils/check-reporter.mjs'
12
12
  import { walkDir } from './utils/walkDir.mjs'
13
13
 
14
14
  /**
@@ -42,18 +42,15 @@ export async function findDockerfilePaths(root) {
42
42
  * @returns {Promise<number>} 0 — все OK, 1 — є зауваження або помилка запуску
43
43
  */
44
44
  export async function check() {
45
- let exitCode = 0
46
- const fail = msg => {
47
- console.log(` ❌ ${msg}`)
48
- exitCode = 1
49
- }
45
+ const reporter = createCheckReporter()
46
+ const { pass, fail } = reporter
50
47
 
51
48
  const root = process.cwd()
52
49
  const files = await findDockerfilePaths(root)
53
50
 
54
51
  if (files.length === 0) {
55
52
  pass('Немає Dockerfile / Containerfile — перевірку hadolint пропущено')
56
- return 0
53
+ return reporter.getExitCode()
57
54
  }
58
55
 
59
56
  pass(`Знайдено файлів для hadolint: ${files.length}`)
@@ -69,5 +66,5 @@ export async function check() {
69
66
  }
70
67
  }
71
68
 
72
- return exitCode
69
+ return reporter.getExitCode()
73
70
  }
@@ -14,7 +14,7 @@ import { existsSync } from 'node:fs'
14
14
  import { readdir, readFile } from 'node:fs/promises'
15
15
  import { join } from 'node:path'
16
16
 
17
- import { pass } from './utils/pass.mjs'
17
+ import { createCheckReporter } from './utils/check-reporter.mjs'
18
18
  import {
19
19
  anyRunStepIncludes,
20
20
  eventPathsIncludeExact,
@@ -122,17 +122,14 @@ function verifyNoDirectBunOrCache(relPath, content, failFn, passFn) {
122
122
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
123
123
  */
124
124
  export async function check() {
125
- let exitCode = 0
126
- const fail = msg => {
127
- console.log(` ❌ ${msg}`)
128
- exitCode = 1
129
- }
125
+ const reporter = createCheckReporter()
126
+ const { pass, fail } = reporter
130
127
 
131
128
  const wfDir = '.github/workflows'
132
129
 
133
130
  if (!existsSync(wfDir)) {
134
131
  fail(`Директорія ${wfDir} не існує`)
135
- return exitCode
132
+ return reporter.getExitCode()
136
133
  }
137
134
 
138
135
  const setupBunDepsAction = '.github/actions/setup-bun-deps/action.yml'
@@ -290,5 +287,5 @@ export async function check() {
290
287
  }
291
288
  }
292
289
 
293
- return exitCode
290
+ return reporter.getExitCode()
294
291
  }
@@ -6,18 +6,15 @@
6
6
  import { existsSync } from 'node:fs'
7
7
  import { readFile } from 'node:fs/promises'
8
8
 
9
- import { pass } from './utils/pass.mjs'
9
+ import { createCheckReporter } from './utils/check-reporter.mjs'
10
10
 
11
11
  /**
12
12
  * Перевіряє відповідність проєкту правилам js-format.mdc
13
13
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
14
14
  */
15
15
  export async function check() {
16
- let exitCode = 0
17
- const fail = msg => {
18
- console.log(` ❌ ${msg}`)
19
- exitCode = 1
20
- }
16
+ const reporter = createCheckReporter()
17
+ const { pass, fail } = reporter
21
18
 
22
19
  const expectedKeys = [
23
20
  'arrowParens',
@@ -95,5 +92,5 @@ export async function check() {
95
92
  if (pkg.prettier) fail('package.json містить поле "prettier" — видали його')
96
93
  }
97
94
 
98
- return exitCode
95
+ return reporter.getExitCode()
99
96
  }
@@ -10,7 +10,7 @@ import { existsSync } from 'node:fs'
10
10
  import { readFile } from 'node:fs/promises'
11
11
 
12
12
  import { parseWorkflowYaml, verifyLintJsWorkflowStructure } from './utils/gha-workflow.mjs'
13
- import { pass } from './utils/pass.mjs'
13
+ import { createCheckReporter } from './utils/check-reporter.mjs'
14
14
 
15
15
  /** Очікуваний локальний скрипт. */
16
16
  export const CANONICAL_LINT_JS = 'bunx oxlint --fix && bunx eslint --fix . && bunx jscpd .'
@@ -41,11 +41,8 @@ export function isCanonicalLintJs(script) {
41
41
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
42
42
  */
43
43
  export async function check() {
44
- let exitCode = 0
45
- const fail = msg => {
46
- console.log(` ❌ ${msg}`)
47
- exitCode = 1
48
- }
44
+ const reporter = createCheckReporter()
45
+ const { pass, fail } = reporter
49
46
 
50
47
  let eslintPath = ''
51
48
  if (existsSync('eslint.config.js')) {
@@ -242,5 +239,5 @@ export async function check() {
242
239
  if (existsSync(dup)) fail(`Знайдено застарілий конфіг ESLint: ${dup} — видали, використовуй flat config`)
243
240
  }
244
241
 
245
- return exitCode
242
+ return reporter.getExitCode()
246
243
  }
@@ -8,16 +8,17 @@ import { existsSync } from 'node:fs'
8
8
  import { readFile } from 'node:fs/promises'
9
9
  import { join } from 'node:path'
10
10
 
11
- import { pass } from './utils/pass.mjs'
11
+ import { createCheckReporter } from './utils/check-reporter.mjs'
12
12
  import { getMonorepoPackageRootDirs } from './utils/workspaces.mjs'
13
13
 
14
14
  /**
15
15
  * Перевіряє відповідність правилам js-pino.mdc для одного workspace-пакета.
16
16
  * @param {string} rootDir відносний шлях workspace (не `'.'`)
17
17
  * @param {(msg: string) => void} fail функція зворотного виклику для реєстрації помилки перевірки
18
+ * @param {(msg: string) => void} passFn успішне повідомлення (як у check-reporter)
18
19
  * @returns {Promise<void>} завершується після перевірок цього пакета
19
20
  */
20
- async function checkWorkspacePackage(rootDir, fail) {
21
+ async function checkWorkspacePackage(rootDir, fail, passFn) {
21
22
  const label = `[${rootDir}] `
22
23
  const pkgPath = join(rootDir, 'package.json')
23
24
  if (existsSync(pkgPath)) {
@@ -36,9 +37,9 @@ async function checkWorkspacePackage(rootDir, fail) {
36
37
  if (existsSync(configmapPath)) {
37
38
  const content = await readFile(configmapPath, 'utf8')
38
39
  if (content.includes('OTEL_RESOURCE_ATTRIBUTES')) {
39
- pass(`${label}k8s/base/configmap.yaml містить OTEL_RESOURCE_ATTRIBUTES`)
40
+ passFn(`${label}k8s/base/configmap.yaml містить OTEL_RESOURCE_ATTRIBUTES`)
40
41
  if (content.includes('service.name=') && content.includes('service.namespace=')) {
41
- pass(`${label}OTEL_RESOURCE_ATTRIBUTES містить service.name та service.namespace`)
42
+ passFn(`${label}OTEL_RESOURCE_ATTRIBUTES містить service.name та service.namespace`)
42
43
  } else {
43
44
  fail(`${label}OTEL_RESOURCE_ATTRIBUTES має містити service.name=<name>,service.namespace=<namespace>`)
44
45
  }
@@ -53,23 +54,20 @@ async function checkWorkspacePackage(rootDir, fail) {
53
54
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
54
55
  */
55
56
  export async function check() {
56
- let exitCode = 0
57
- const fail = msg => {
58
- console.log(` ❌ ${msg}`)
59
- exitCode = 1
60
- }
57
+ const reporter = createCheckReporter()
58
+ const { pass, fail } = reporter
61
59
 
62
60
  const roots = await getMonorepoPackageRootDirs()
63
61
  const workspaceRoots = roots.filter(r => r !== '.')
64
62
 
65
63
  if (workspaceRoots.length === 0) {
66
64
  pass('js-pino: немає workspace-пакетів у кореневому package.json — перевірку залежностей і k8s у пакетах пропущено')
67
- return exitCode
65
+ return reporter.getExitCode()
68
66
  }
69
67
 
70
68
  for (const r of workspaceRoots) {
71
- await checkWorkspacePackage(r, fail)
69
+ await checkWorkspacePackage(r, fail, pass)
72
70
  }
73
71
 
74
- return exitCode
72
+ return reporter.getExitCode()
75
73
  }
@@ -51,7 +51,7 @@ import { basename, dirname, join, relative, resolve } from 'node:path'
51
51
 
52
52
  import { parseAllDocuments } from 'yaml'
53
53
 
54
- import { pass } from './utils/pass.mjs'
54
+ import { createCheckReporter } from './utils/check-reporter.mjs'
55
55
  import { walkDir } from './utils/walkDir.mjs'
56
56
 
57
57
  /** Версія набору схем yannh — узгоджено з k8s.mdc */
@@ -1482,11 +1482,8 @@ async function ensureBaseKustomizationHasNamespace(root, yamlFiles, fail) {
1482
1482
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
1483
1483
  */
1484
1484
  export async function check() {
1485
- let exitCode = 0
1486
- const fail = msg => {
1487
- console.log(` ❌ ${msg}`)
1488
- exitCode = 1
1489
- }
1485
+ const reporter = createCheckReporter()
1486
+ const { pass, fail } = reporter
1490
1487
 
1491
1488
  const root = process.cwd()
1492
1489
 
@@ -1496,7 +1493,7 @@ export async function check() {
1496
1493
 
1497
1494
  if (yamlFiles.length === 0) {
1498
1495
  pass('Немає *.yaml під k8s — перевірку $schema пропущено')
1499
- return 0
1496
+ return reporter.getExitCode()
1500
1497
  }
1501
1498
 
1502
1499
  pass(`YAML у k8s: ${yamlFiles.length} файл(ів)`)
@@ -1515,5 +1512,5 @@ export async function check() {
1515
1512
 
1516
1513
  await ensureBaseKustomizationHasNamespace(root, yamlFiles, fail)
1517
1514
 
1518
- return exitCode
1515
+ return reporter.getExitCode()
1519
1516
  }
@@ -18,7 +18,7 @@ import { readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
18
18
  import { basename, dirname, join, relative } from 'node:path'
19
19
 
20
20
  import { findDockerfilePaths } from './check-docker.mjs'
21
- import { pass } from './utils/pass.mjs'
21
+ import { createCheckReporter } from './utils/check-reporter.mjs'
22
22
  import { walkDir } from './utils/walkDir.mjs'
23
23
 
24
24
  /**
@@ -264,11 +264,8 @@ function dockerfileHasEnvsSubstTemplate(dockerfileContent) {
264
264
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
265
265
  */
266
266
  export async function check() {
267
- let exitCode = 0
268
- const fail = msg => {
269
- console.log(` ❌ ${msg}`)
270
- exitCode = 1
271
- }
267
+ const reporter = createCheckReporter()
268
+ const { pass, fail } = reporter
272
269
 
273
270
  const root = process.cwd()
274
271
 
@@ -284,7 +281,7 @@ export async function check() {
284
281
 
285
282
  if (templates.length === 0) {
286
283
  pass('Немає default.conf.template — перевірку nginx-default-tpl пропущено')
287
- return 0
284
+ return reporter.getExitCode()
288
285
  }
289
286
 
290
287
  pass(`Знайдено default.conf.template: ${templates.length}`)
@@ -383,5 +380,5 @@ export async function check() {
383
380
  fail('Очікується .vscode/settings.json з форматером nginx і formatOnSave (див. nginx-default-tpl.mdc)')
384
381
  }
385
382
 
386
- return exitCode
383
+ return reporter.getExitCode()
387
384
  }
@@ -7,7 +7,7 @@
7
7
  import { existsSync } from 'node:fs'
8
8
  import { readFile, stat } from 'node:fs/promises'
9
9
 
10
- import { pass } from './utils/pass.mjs'
10
+ import { createCheckReporter } from './utils/check-reporter.mjs'
11
11
  import {
12
12
  hasIdTokenWritePermission,
13
13
  hasNpmPublishStepWithPackage,
@@ -21,11 +21,8 @@ import {
21
21
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
22
22
  */
23
23
  export async function check() {
24
- let exitCode = 0
25
- const fail = msg => {
26
- console.log(` ❌ ${msg}`)
27
- exitCode = 1
28
- }
24
+ const reporter = createCheckReporter()
25
+ const { pass, fail } = reporter
29
26
 
30
27
  if (existsSync('package.json')) {
31
28
  pass('package.json існує')
@@ -114,5 +111,5 @@ export async function check() {
114
111
  fail(`Відсутній ${publishWf} (npm-module.mdc: npm publish)`)
115
112
  }
116
113
 
117
- return exitCode
114
+ return reporter.getExitCode()
118
115
  }
@@ -8,7 +8,7 @@
8
8
  import { existsSync } from 'node:fs'
9
9
  import { readFile } from 'node:fs/promises'
10
10
 
11
- import { pass } from './utils/pass.mjs'
11
+ import { createCheckReporter } from './utils/check-reporter.mjs'
12
12
  import { anyRunStepIncludesStylelint, parseWorkflowYaml } from './utils/gha-workflow.mjs'
13
13
 
14
14
  /**
@@ -16,11 +16,8 @@ import { anyRunStepIncludesStylelint, parseWorkflowYaml } from './utils/gha-work
16
16
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
17
17
  */
18
18
  export async function check() {
19
- let exitCode = 0
20
- const fail = msg => {
21
- console.log(` ❌ ${msg}`)
22
- exitCode = 1
23
- }
19
+ const reporter = createCheckReporter()
20
+ const { pass, fail } = reporter
24
21
 
25
22
  if (existsSync('package.json')) {
26
23
  const pkg = JSON.parse(await readFile('package.json', 'utf8'))
@@ -105,5 +102,5 @@ export async function check() {
105
102
  }
106
103
  }
107
104
 
108
- return exitCode
105
+ return reporter.getExitCode()
109
106
  }
@@ -12,7 +12,7 @@
12
12
  import { existsSync } from 'node:fs'
13
13
  import { readFile } from 'node:fs/promises'
14
14
 
15
- import { pass } from './utils/pass.mjs'
15
+ import { createCheckReporter } from './utils/check-reporter.mjs'
16
16
  import { anyRunStepIncludes, parseWorkflowYaml } from './utils/gha-workflow.mjs'
17
17
 
18
18
  /** Заголовок абзацу про апостроф у text.mdc / n-text.mdc. */
@@ -47,11 +47,8 @@ function verifyUkApostropheRuleParagraph(filePath, body, failFn, passFn) {
47
47
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
48
48
  */
49
49
  export async function check() {
50
- let exitCode = 0
51
- const fail = msg => {
52
- console.log(` ❌ ${msg}`)
53
- exitCode = 1
54
- }
50
+ const reporter = createCheckReporter()
51
+ const { pass, fail } = reporter
55
52
 
56
53
  const v8rIgnoreRequired = ['.vscode/extensions.json', '.vscode/settings.json']
57
54
  if (existsSync('.v8rignore')) {
@@ -215,5 +212,5 @@ export async function check() {
215
212
  }
216
213
  }
217
214
 
218
- return exitCode
215
+ return reporter.getExitCode()
219
216
  }
@@ -11,7 +11,7 @@ import { existsSync } from 'node:fs'
11
11
  import { readFile } from 'node:fs/promises'
12
12
  import { join, relative } from 'node:path'
13
13
 
14
- import { pass } from './utils/pass.mjs'
14
+ import { createCheckReporter } from './utils/check-reporter.mjs'
15
15
  import {
16
16
  findForbiddenVueImportsInSourceFile,
17
17
  isVueImportScanSourceFile,
@@ -53,9 +53,10 @@ function ukFilesCountPhrase(n) {
53
53
  * Перевіряє залежності та vite.config одного Vue-пакета.
54
54
  * @param {string} rootDir відносний шлях до пакета
55
55
  * @param {(msg: string) => void} fail функція зворотного виклику для реєстрації помилки перевірки
56
+ * @param {(msg: string) => void} passFn успішне повідомлення (як у check-reporter)
56
57
  * @returns {Promise<void>} завершується після перевірок залежностей, `vite.config` і сканування джерел на імпорти з `vue`
57
58
  */
58
- async function checkVuePackage(rootDir, fail) {
59
+ async function checkVuePackage(rootDir, fail, passFn) {
59
60
  const label = packageLabel(rootDir)
60
61
  const prefix = `[${label}] `
61
62
 
@@ -66,7 +67,7 @@ async function checkVuePackage(rootDir, fail) {
66
67
  const allDeps = { ...deps, ...devDeps }
67
68
 
68
69
  if (deps.vue) {
69
- pass(`${prefix}vue в dependencies: ${deps.vue}`)
70
+ passFn(`${prefix}vue в dependencies: ${deps.vue}`)
70
71
  } else {
71
72
  fail(`${prefix}vue відсутній в dependencies`)
72
73
  }
@@ -74,7 +75,7 @@ async function checkVuePackage(rootDir, fail) {
74
75
  if (devDeps.vite) {
75
76
  const match = devDeps.vite.match(/(\d+)/)
76
77
  if (match && Number(match[1]) >= 8) {
77
- pass(`${prefix}vite >= 8: ${devDeps.vite}`)
78
+ passFn(`${prefix}vite >= 8: ${devDeps.vite}`)
78
79
  } else {
79
80
  fail(`${prefix}vite має бути >= 8, знайдено: ${devDeps.vite}`)
80
81
  }
@@ -83,25 +84,25 @@ async function checkVuePackage(rootDir, fail) {
83
84
  }
84
85
 
85
86
  if (devDeps['@vitejs/plugin-vue']) {
86
- pass(`${prefix}@vitejs/plugin-vue: ${devDeps['@vitejs/plugin-vue']}`)
87
+ passFn(`${prefix}@vitejs/plugin-vue: ${devDeps['@vitejs/plugin-vue']}`)
87
88
  } else {
88
89
  fail(`${prefix}@vitejs/plugin-vue відсутній в devDependencies`)
89
90
  }
90
91
 
91
92
  if (allDeps['vue-macros']) {
92
- pass(`${prefix}vue-macros: ${allDeps['vue-macros']}`)
93
+ passFn(`${prefix}vue-macros: ${allDeps['vue-macros']}`)
93
94
  } else {
94
95
  fail(`${prefix}vue-macros відсутній — bun add -d vue-macros`)
95
96
  }
96
97
 
97
98
  if (allDeps['unplugin-auto-import']) {
98
- pass(`${prefix}unplugin-auto-import присутній`)
99
+ passFn(`${prefix}unplugin-auto-import присутній`)
99
100
  } else {
100
101
  fail(`${prefix}unplugin-auto-import відсутній — bun add -d unplugin-auto-import`)
101
102
  }
102
103
 
103
104
  if (allDeps['vite-plugin-vue-layouts-next']) {
104
- pass(`${prefix}vite-plugin-vue-layouts-next присутній`)
105
+ passFn(`${prefix}vite-plugin-vue-layouts-next присутній`)
105
106
  } else {
106
107
  fail(`${prefix}vite-plugin-vue-layouts-next відсутній — bun add -d vite-plugin-vue-layouts-next`)
107
108
  }
@@ -112,12 +113,12 @@ async function checkVuePackage(rootDir, fail) {
112
113
  const relConfig = join(rootDir, viteConfig)
113
114
  const content = await readFile(relConfig, 'utf8')
114
115
  if (content.includes('VueMacros')) {
115
- pass(`${prefix}${viteConfig} використовує VueMacros`)
116
+ passFn(`${prefix}${viteConfig} використовує VueMacros`)
116
117
  } else {
117
118
  fail(`${prefix}${viteConfig} не містить VueMacros`)
118
119
  }
119
120
  if (content.includes('AutoImport')) {
120
- pass(`${prefix}${viteConfig} використовує AutoImport`)
121
+ passFn(`${prefix}${viteConfig} використовує AutoImport`)
121
122
  } else {
122
123
  fail(`${prefix}${viteConfig} не містить AutoImport`)
123
124
  }
@@ -147,7 +148,7 @@ async function checkVuePackage(rootDir, fail) {
147
148
  }
148
149
  }
149
150
  if (importViolations === 0) {
150
- pass(
151
+ passFn(
151
152
  `${prefix}немає заборонених value-імпортів з 'vue' у джерелах (проскановано ${ukFilesCountPhrase(sourcePaths.length)})`
152
153
  )
153
154
  }
@@ -158,11 +159,8 @@ async function checkVuePackage(rootDir, fail) {
158
159
  * @returns {Promise<number>} 0 — все OK, 1 — є проблеми
159
160
  */
160
161
  export async function check() {
161
- let exitCode = 0
162
- const fail = msg => {
163
- console.log(` ❌ ${msg}`)
164
- exitCode = 1
165
- }
162
+ const reporter = createCheckReporter()
163
+ const { pass, fail } = reporter
166
164
 
167
165
  if (existsSync('.vscode/extensions.json')) {
168
166
  const ext = JSON.parse(await readFile('.vscode/extensions.json', 'utf8'))
@@ -188,12 +186,12 @@ export async function check() {
188
186
 
189
187
  if (vueRoots.length === 0) {
190
188
  fail('vue не знайдено в dependencies жодного пакета (корінь репо та каталоги з кореневого workspaces)')
191
- return exitCode
189
+ return reporter.getExitCode()
192
190
  }
193
191
 
194
192
  for (const r of vueRoots) {
195
- await checkVuePackage(r, fail)
193
+ await checkVuePackage(r, fail, pass)
196
194
  }
197
195
 
198
- return exitCode
196
+ return reporter.getExitCode()
199
197
  }
@@ -11,7 +11,7 @@ import { basename } from 'node:path'
11
11
 
12
12
  import { isRunAsCli } from './cli-entry.mjs'
13
13
  import { lintDockerfileWithHadolint, posixRel } from './utils/docker-hadolint.mjs'
14
- import { pass } from './utils/pass.mjs'
14
+ import { createCheckReporter } from './utils/check-reporter.mjs'
15
15
  import { walkDir } from './utils/walkDir.mjs'
16
16
 
17
17
  /**
@@ -44,18 +44,15 @@ export async function findLintDockerfilePaths(root) {
44
44
  * @returns {Promise<number>} 0 — OK, 1 — зауваження або помилка
45
45
  */
46
46
  async function main() {
47
- let exitCode = 0
48
- const fail = msg => {
49
- console.log(` ❌ ${msg}`)
50
- exitCode = 1
51
- }
47
+ const reporter = createCheckReporter()
48
+ const { pass, fail } = reporter
52
49
 
53
50
  const root = process.cwd()
54
51
  const files = await findLintDockerfilePaths(root)
55
52
 
56
53
  if (files.length === 0) {
57
54
  pass('lint-docker: немає Dockerfile / *.Dockerfile — hadolint пропущено')
58
- return 0
55
+ return reporter.getExitCode()
59
56
  }
60
57
 
61
58
  pass(`lint-docker: файлів для hadolint: ${files.length}`)
@@ -71,7 +68,7 @@ async function main() {
71
68
  }
72
69
  }
73
70
 
74
- return exitCode
71
+ return reporter.getExitCode()
75
72
  }
76
73
 
77
74
  if (isRunAsCli()) {
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Спільний репортер для check-скриптів і lint-docker.
3
+ *
4
+ * Об’єднує вивід успіхів (`pass` з `pass.mjs`) і помилок з префіксом ❌; накопичує код виходу **1**,
5
+ * якщо хоча б раз викликано `fail`.
6
+ *
7
+ * Використовуй `getExitCode()` у `return`, а не деструктуризацію `exitCode` — геттер «знімається» один раз.
8
+ */
9
+ import { pass } from './pass.mjs'
10
+
11
+ /**
12
+ * Створює пару `pass` / `fail` з накопиченням ненульового коду виходу.
13
+ * @returns {{ pass: typeof pass, fail: (msg: string) => void, getExitCode: () => number }}
14
+ */
15
+ export function createCheckReporter() {
16
+ let exitCode = 0
17
+ return {
18
+ pass,
19
+ fail(msg) {
20
+ console.log(` ❌ ${msg}`)
21
+ exitCode = 1
22
+ },
23
+ getExitCode() {
24
+ return exitCode
25
+ }
26
+ }
27
+ }